Skip to content

Add module-scoped ALC bootstrapper generation#354

Merged
PrzemyslawKlys merged 9 commits intomainfrom
codex/module-scoped-alc-loader
May 8, 2026
Merged

Add module-scoped ALC bootstrapper generation#354
PrzemyslawKlys merged 9 commits intomainfrom
codex/module-scoped-alc-loader

Conversation

@PrzemyslawKlys
Copy link
Copy Markdown
Member

Summary

  • adds an opt-in NETAssemblyLoadContext / UseAssemblyLoadContext build setting for binary modules
  • generates a module-scoped AssemblyLoadContext loader DLL, for example PSParseHTML.ModuleLoadContext.dll, instead of sharing a common loader assembly across modules
  • updates the generated Core bootstrapper to load binary modules through the module-scoped loader while keeping Desktop behavior on the normal import path

Validation

  • dotnet build .\PSPublishModule\PSPublishModule.csproj -c Debug -f net8.0
  • dotnet build .\PowerForge.Tests\PowerForge.Tests.csproj -c Debug
  • dotnet test .\PowerForge.Tests\PowerForge.Tests.csproj -c Debug --no-build --filter "FullyQualifiedName~BuildConfigurationFactoryTests|FullyQualifiedName~ModulePipelineExportAssemblyInferenceTests|FullyQualifiedName~ModuleBootstrapperGeneratorTests"
  • git diff --check

@claude
Copy link
Copy Markdown

claude Bot commented May 8, 2026

Code Review: Add module-scoped ALC bootstrapper generation

Overview

This PR adds an opt-in NETAssemblyLoadContext / UseAssemblyLoadContext switch that generates a module-scoped AssemblyLoadContext loader DLL at build time and wires the generated .psm1 bootstrapper to use it on PowerShell Core. Desktop behavior is unchanged. The feature plumbing (cmdlet → request model → factory → plan → pipeline) is consistent with existing patterns in the codebase.


Positives

  • Clean layering through the stack: cmdlet parameter → BuildConfigurationRequest → factory → ModuleBuildSpec → plan → pipeline → generator.
  • Both naming variants (UseAssemblyLoadContext / NETAssemblyLoadContext) are supported in the legacy adapter and JSON schema, which avoids breaking existing configs.
  • The generated ALC source is solid: static Contexts dictionary keyed by full path prevents duplicate contexts for the same assembly, isCollectible: false is correct (modules shouldn't be GC'd mid-session), and Load correctly short-circuits for the loader assembly itself to prevent infinite recursion.
  • Temp-dir cleanup is correctly wrapped in finally/best-effort catch.
  • The loader DLL is intentionally not copied to the Default directory (Desktop PowerShell doesn't have AssemblyLoadContext).

Issues & Suggestions

1. CreateAssemblyLoadContextLoaderIdentity called twice unnecessarily

In BuildBootstrapperPsm1, the identity is computed twice in the same token dictionary:

["LoaderAssemblyName"] = EscapePsSingleQuoted(CreateAssemblyLoadContextLoaderIdentity(moduleName).AssemblyName),
["LoaderTypeName"]     = CreateAssemblyLoadContextLoaderIdentity(moduleName).TypeName,

Call it once before the dictionary initializer and reuse the result.

2. New test actually runs dotnet build — integration test, not unit test

Generate_WithAssemblyLoadContext_WritesAlcBootstrapperAndSkipsLibrariesScript invokes ModuleBootstrapperGenerator.Generate with useAssemblyLoadContext: true, which triggers a real dotnet build inside BuildAssemblyLoadContextLoader. This makes it a slow integration test (2-minute timeout) that requires the dotnet SDK at test time. Consider:

  • Marking it with [Trait("Category", "Integration")] so it can be filtered separately.
  • Or mocking/abstracting the compilation step so the unit test validates only string outputs.

3. Hardcoded 2-minute dotnet build timeout may be tight in CI

The TimeSpan.FromMinutes(2) timeout for the generated project compilation is generous for local dev but could race on heavily loaded CI agents. On a cold cache (first NuGet restore on a clean agent), even though this project has no package dependencies, the SDK toolchain initialisation alone can exceed 2 minutes. Consider exposing this as a configurable value or slightly increasing the default.

4. Non-public AddExportedCmdlet reflection — risk undocumented

$AddExportedCmdlet = [System.Management.Automation.PSModuleInfo].GetMethod(
    'AddExportedCmdlet',
    [System.Reflection.BindingFlags]'Instance, NonPublic'
)

This is a known community workaround, but it depends on a private PowerShell API that could be renamed or removed in a future PS version. A comment in the template explaining why this is needed and the associated risk would help future maintainers.

5. Write-Error without return leaves $Framework undefined on the error path

In AssemblyLoadContextBinaryLoader.Template.ps1, the else { Write-Error ... } branch (no assemblies found) doesn't stop script execution unless the caller has set -ErrorAction Stop. The code then falls through to $LibFolder = $Framework, which would be undefined. This is inherited from the existing BinaryLoader.Template.ps1, so it is not a regression, but worth fixing in both templates at some point.

6. Minor: dead-code guard in ToCSharpIdentifier

if (sb.Length == 0 || !(char.IsLetter(sb[0]) || sb[0] == '_'))
    sb.Insert(0, '_');

Because the loop replaces every invalid character (including leading digits) with _, the first character of sb will already be _ if the original first character was a digit. The sb.Insert branch is never reached. The guard is harmless but confusing.

7. Generated project targets net6.0 — consider net8.0 or netstandard2.0

The loader project targets net6.0, which is already out of support. Since the code only uses BCL types (AssemblyLoadContext, AssemblyDependencyResolver), targeting net8.0 (current LTS) would be more forward-looking. Alternatively, since AssemblyDependencyResolver lives in netcoreapp3.0+, a net8.0 target with a <RuntimeIdentifier> would also work. Either way, net6.0 is not wrong, but it will eventually need updating.


Security

No concerns. User-supplied module names pass through ToCSharpIdentifierPath (for C# source) and SanitizeAssemblyName/EscapeXml (for the .csproj), and through EscapePsSingleQuoted (for the .psm1 template). The runtime dotnet build invocation uses a process runner with an explicit argument list — no shell interpolation.


Test Coverage

  • BuildConfigurationFactoryTests is updated correctly.
  • ModuleBootstrapperGeneratorTests adds the new ALC test (issues noted above).
  • No tests for ModulePipelineExportAssemblyInferenceTests changes — PR description mentions the test filter includes them; if they already exist and pass, that is sufficient.

Summary

The implementation is sound and consistent with the existing codebase conventions. The items above are mostly polish:

  • Actionable before merge: Items 1 (double identity call), 2 (integration test label), 4 (reflection comment).
  • Nice-to-have / follow-up: Items 3, 5, 6, 7.

Good work overall — the ALC isolation approach is the right solution for dependency conflict problems in binary modules.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: af2f8451a3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

private static string BuildAssemblyLoadContextProject(AssemblyLoadContextLoaderIdentity identity)
=> $@"<Project Sdk=""Microsoft.NET.Sdk"">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid hardcoding net6.0 for generated loader project

The temporary loader project is always emitted as net6.0, which forces dotnet build to resolve .NET 6 reference packs even when the host build is on newer SDKs. In CI/offline environments (or agents without cached/net6 targeting packs), this makes UseAssemblyLoadContext builds fail during restore/build despite the module itself targeting newer frameworks. Consider targeting the current build TFM (or a bundled/prebuilt loader) to remove this fragile dependency.

Useful? React with 👍 / 👎.

"NETBinaryModuleDocumentation": { "type": ["boolean", "null"] },
"HandleRuntimes": { "type": ["boolean", "null"] },
"UseAssemblyLoadContext": { "type": ["boolean", "null"] },
"NETAssemblyLoadContext": { "type": ["boolean", "null"] },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove unsupported NETAssemblyLoadContext schema alias

The schema now advertises NETAssemblyLoadContext, but the typed JSON segment model only exposes UseAssemblyLoadContext (no matching property or alias mapping), so JSON configs that set NETAssemblyLoadContext are silently ignored during deserialization. This creates a contract mismatch where validated config does not actually enable ALC loading.

Useful? React with 👍 / 👎.

private static string BuildAssemblyLoadContextProject(AssemblyLoadContextLoaderIdentity identity)
=> $@"<Project Sdk=""Microsoft.NET.Sdk"">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid hardcoding net6.0 for generated loader project

The temporary loader project is always emitted as net6.0, which forces a .NET 6 targeting-pack restore even when the build uses newer SDK/runtime stacks. In offline or minimal CI agents this makes UseAssemblyLoadContext builds fail solely because net6 reference packs are unavailable, despite the module itself targeting other TFMs.

Useful? React with 👍 / 👎.

"dotnet",
buildRoot,
new[] { "build", projectPath, "-c", "Release", "-o", outputRoot, "-nologo", "-v:minimal" },
TimeSpan.FromMinutes(2));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Increase loader build timeout beyond two minutes

The generated loader build is capped at a hard 2-minute timeout. First-run restores, cold package caches, or slower CI runners can easily exceed this and produce exit code 124, causing module builds to fail nondeterministically even though dotnet build would complete successfully with a normal timeout window.

Useful? React with 👍 / 👎.

Comment on lines +41 to +45
if (hasLib && useAssemblyLoadContext)
{
BuildAssemblyLoadContextLoader(root, moduleName);
}
else if (hasLib)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Desktop libraries script in ALC mode

When useAssemblyLoadContext is enabled, this branch skips generating <Module>.Libraries.ps1 entirely. The new ALC bootstrapper still uses the normal Desktop import path, but without the old libraries script dot-source step, extra module DLLs are no longer preloaded on Windows PowerShell. Modules that relied on those additional assemblies being loaded can regress even though the option is intended to affect Core loading behavior.

Useful? React with 👍 / 👎.

"NETBinaryModuleDocumentation": { "type": ["boolean", "null"] },
"HandleRuntimes": { "type": ["boolean", "null"] },
"UseAssemblyLoadContext": { "type": ["boolean", "null"] },
"NETAssemblyLoadContext": { "type": ["boolean", "null"] },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove unsupported NETAssemblyLoadContext schema alias

The schema now advertises NETAssemblyLoadContext, but the typed JSON segment model only exposes UseAssemblyLoadContext and doesn't map this alias during deserialization. As a result, JSON configs that pass schema validation with NETAssemblyLoadContext silently do nothing, which is a contract mismatch for users authoring pipeline JSON directly.

Useful? React with 👍 / 👎.

private static string BuildAssemblyLoadContextProject(AssemblyLoadContextLoaderIdentity identity)
=> $@"<Project Sdk=""Microsoft.NET.Sdk"">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid hardcoding net6.0 for generated loader project

The temporary loader project is always emitted as net6.0, which forces a .NET 6 targeting-pack restore even when the build uses newer SDK/runtime stacks. In offline or minimal CI agents this can make UseAssemblyLoadContext builds fail solely because net6 reference packs aren't available, despite the module itself targeting other frameworks.

Useful? React with 👍 / 👎.

"dotnet",
buildRoot,
new[] { "build", projectPath, "-c", "Release", "-o", outputRoot, "-nologo", "-v:minimal" },
TimeSpan.FromMinutes(2));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Increase loader build timeout beyond two minutes

The generated loader build is capped at a hard 2-minute timeout. First-run restores, cold package caches, or slower CI runners can exceed this and return a timeout exit code, causing nondeterministic module build failures even when dotnet build would otherwise complete successfully.

Useful? React with 👍 / 👎.

@claude
Copy link
Copy Markdown

claude Bot commented May 8, 2026

Code Review: Add module-scoped ALC bootstrapper generation

Note: A prior automated review comment on this PR contains several factual errors (wrong timeout value, claims about code that doesn't exist, suggestions already implemented in the diff). This review is based on a fresh read of the actual diff.


Overview

This PR adds an opt-in NETAssemblyLoadContext/UseAssemblyLoadContext switch that generates a module-scoped AssemblyLoadContext loader DLL at build time and wires the generated .psm1 bootstrapper to use it on PowerShell Core. Desktop behavior is unchanged. The end-to-end plumbing (cmdlet parameter → BuildConfigurationRequest → factory → ModuleBuildSpec → plan → pipeline → generator) is consistent with existing patterns throughout the codebase.


What the diff actually contains (correcting the record)

A few things the earlier automated review got wrong — important to note for anyone reading that comment:

  • CreateAssemblyLoadContextLoaderIdentity is called once, stored in loaderIdentity, and then used via loaderIdentity?.AssemblyName and loaderIdentity?.TypeName. It is not called twice.
  • The new test already carries [Trait("Category", "Integration")] (line 162 in ModuleBootstrapperGeneratorTests.cs).
  • The build timeout is TimeSpan.FromMinutes(5), not 2 minutes.
  • There is no sb.Insert guard in ToCSharpIdentifier — that code does not exist in this PR.
  • The generated project targets net8.0, not net6.0.

Genuine issues

1. AddExportedCmdlet reflection: missing null guard (potential crash on future PS)

In AssemblyLoadContextBinaryLoader.Template.ps1:

$AddExportedCmdlet = [System.Management.Automation.PSModuleInfo].GetMethod(
    'AddExportedCmdlet',
    [System.Reflection.BindingFlags]'Instance, NonPublic'
)
foreach ($Cmd in $InnerModule.ExportedCmdlets.Values) {
    $AddExportedCmdlet.Invoke(...)   # throws NullReferenceException if GetMethod returns $null
}

GetMethod returns $null if the private method is renamed or removed in a future PowerShell version. Invoking $null throws immediately. A minimal fix:

if ($null -ne $AddExportedCmdlet) {
    foreach ($Cmd in $InnerModule.ExportedCmdlets.Values) {
        $AddExportedCmdlet.Invoke($ExecutionContext.SessionState.Module, @(, $Cmd)) | Out-Null
    }
} else {
    Write-Warning "AddExportedCmdlet is not available on this PowerShell version; cmdlets from $LibraryName may not be exported."
}

The comment in the template explains why this hook is needed, which is good — it just needs the safety net for the $null case.

2. Write-Error without return leaves $Framework undefined (inherited, but now duplicated)

The else { Write-Error -Message 'No assemblies found' } branch in the framework-detection block does not stop execution unless the caller set -ErrorAction Stop. If no assemblies are found, $Framework is never assigned, and the subsequent $LibFolder = $Framework runs with an undefined variable, producing a silent empty string rather than an error. This is copied from BinaryLoader.Template.ps1 (pre-existing), but now lives in two places. A return (or throw) after the Write-Error would fix both:

} else {
    Write-Error -Message 'No assemblies found'
    return
}

3. Libraries.ps1 is not sourced on PowerShell Core with the ALC template

The new template sources {{ModuleName}}.Libraries.ps1 only on Desktop:

if ($PSEdition -ne 'Core') {
    $LibrariesScript = [IO.Path]::Combine($PSScriptRoot, '{{ModuleName}}.Libraries.ps1')
    if (Test-Path -LiteralPath $LibrariesScript) { . $LibrariesScript }
}

If Libraries.ps1 contains script-only functions or format/type file dot-sources needed on Core, they will be silently skipped. This is probably intentional for modules using ALC (the binary carries all behaviour), but it should be documented — either in a comment in the template or in the feature's documentation — so future maintainers know this is a deliberate trade-off rather than an omission.


What is done well

  • The static Contexts dictionary keyed by full path correctly prevents duplicate ALC instances for the same assembly within a session, and isCollectible: false is the right choice (module contexts shouldn't be GC'd mid-session).
  • Load short-circuits for the loader assembly itself, which prevents the infinite-recursion trap that catches many first-time ALC implementations.
  • Temp-dir cleanup is correctly wrapped in finally with a best-effort catch.
  • The loader DLL is intentionally skipped for the Default (Desktop) directory — Desktop PowerShell has no AssemblyLoadContext.
  • EscapeXml, EscapePsSingleQuoted, ToCSharpIdentifierPath, and SanitizeAssemblyName are all applied at the right boundaries; no unsanitised module name reaches the generated source, .csproj, or .psm1.
  • Both naming variants (UseAssemblyLoadContext / NETAssemblyLoadContext) are handled consistently across the factory, plan, legacy adapter, and JSON schema.
  • The Plan resolver correctly gives priority to UseAssemblyLoadContext over the legacy NETAssemblyLoadContext alias.

Summary

The implementation is architecturally sound and follows the project's conventions well. Two items warrant attention before merge:

  • Block: The AddExportedCmdlet null guard (issue 1) — this is a real crash path on future PowerShell.
  • Recommended: The Write-Error/return gap (issue 2) — low risk today but a latent silent failure.
  • Document: The intentional omission of Libraries.ps1 on Core with the ALC path (issue 3).

@claude
Copy link
Copy Markdown

claude Bot commented May 8, 2026

Code Review — PR #354: Add module-scoped ALC bootstrapper generation

Overview

This PR adds an opt-in NETAssemblyLoadContext / UseAssemblyLoadContext flag that, when set, generates a module-scoped AssemblyLoadContext loader DLL (e.g. PSParseHTML.ModuleLoadContext.dll) and updates the generated .psm1 bootstrapper to load the binary module through it on PowerShell Core, while keeping Desktop behaviour unchanged. The implementation is end-to-end: cmdlet parameter → config model → plan → bootstrapper generator → generated C# source → schema. Overall the approach is sound and the code is clean.


What's good

  • End-to-end coverage — parameter, request model, factory, plan, generator, schema, and PS1 template are all updated consistently.
  • Backward compatible — opt-in flag, existing builds are unaffected.
  • return fix in BinaryLoader.Template.ps1 — good catch; without it, the script continued executing after Write-Error 'No assemblies found'.
  • Temp-directory cleanupBuildAssemblyLoadContextLoader deletes the scratch build in a finally block.
  • Lock around the static Contexts dictionary — correct for a singleton ALC registry.
  • EscapeXml for the generated .csproj — avoids injection when the module name contains XML-special characters.
  • Test coverage — three new targeted tests covering the factory mapping, the plan-level alias resolution, and the bootstrapper output.

Issues

1. Hardcoded net8.0 in the generated project (medium risk)

BuildAssemblyLoadContextProject always emits <TargetFramework>net8.0</TargetFramework>. If the target PowerShell 7.x installation runs on net6.0 or net7.0, the loader assembly may not load. The TFM should be derived from the module's own target framework or made configurable.

// ModuleBootstrapperGenerator.cs
private static string BuildAssemblyLoadContextProject(AssemblyLoadContextLoaderIdentity identity)
    => $@"...
    <TargetFramework>net8.0</TargetFramework>   // ← should match the module's TFM
    ...

2. Sync-over-async in RunProcess (low risk, but worth cleaning up)

private static ProcessRunResult RunProcess(...)
    => new ProcessRunner()
        .RunAsync(new ProcessRunRequest(...))
        .GetAwaiter()
        .GetResult();   // sync-over-async

GetAwaiter().GetResult() can deadlock if the calling synchronisation context has a single-threaded scheduler. ModuleBootstrapperGenerator is a static class, so injecting IProcessRunner isn't straightforward, but at minimum the method could be made async and awaited from Generate (which is already synchronous by contract). Alternatively, making Generate async and propagating that up would let ModuleBuildPipeline await it properly.

3. No dotnet progress visible to the user (UX)

BuildAssemblyLoadContextLoader runs dotnet build with up to a 5-minute timeout, but there is no log/verbose output to the user. From their perspective the overall module build silently stalls. At minimum, a Write-Verbose or log call before launching the process would help.

4. $IsReload / first-load cmdlet export path needs a comment (clarity)

The PS1 template logic is subtle:

$IsReload = $true
if (-not ('{{LoaderTypeName}}' -as [type])) {
    $IsReload = $false
    Add-Type -Path $LoaderAssemblyPath -ErrorAction Stop
}
...
$InnerModule = & $ImportModule -Assembly $ModuleAssembly -Force -PassThru:$IsReload -ErrorAction Stop

if ($InnerModule) {
    # copy cmdlets via AddExportedCmdlet
}

On first load $IsReload = $false, -PassThru:$false$InnerModule is $null → the AddExportedCmdlet block is skipped. If PowerShell does not automatically propagate the cmdlets from the nested binary module to the outer script module in an ALC scenario, first-load imports would silently export nothing. The comment explains what the code does, but not whether first-load exports work without AddExportedCmdlet. Please add a comment (or a test) confirming the first-load path is exercised and cmdlets are accessible.

5. BinaryLoader.Template.ps1 path without quotes (low risk, pre-existing)

$AssemblyFolders = Get-ChildItem -Path $PSScriptRoot\Lib -Directory -ErrorAction SilentlyContinue

The new AssemblyLoadContextBinaryLoader.Template.ps1 copies this unquoted pattern. If $PSScriptRoot contains spaces the path breaks. Both templates should use "$PSScriptRoot\Lib" or (Join-Path $PSScriptRoot 'Lib'). This is pre-existing in the old template but worth fixing in both while touching them.


Minor / nits

  • SanitizeAssemblyName calls (value ?? string.Empty) but the parameter type is string (non-nullable). The null guard is harmless but inconsistent with the rest of the file.
  • The duplicated ternary for UseAssemblyLoadContext / NETAssemblyLoadContext in LegacySegmentAdapter.cs appears in two separate methods. Extracting a tiny helper (e.g. GetBoolWithFallback(dict, "UseAssemblyLoadContext", "NETAssemblyLoadContext")) would match the style used for ExcludeLibraryFilter / NETExcludeLibraryFilter.
  • AssemblyLoadContextLoaderBuildTimeout (5 min) is a private field today; once users hit slow CI agents it will come up as a config request — worth noting in a code comment even if not exposed yet.

Summary

The feature logic is correct and the implementation is consistent across all layers. The three actionable items to address before merge are:

  1. Hardcoded net8.0 — this will break users on PS 7.2 (net6.0) or 7.3 (net7.0).
  2. First-load cmdlet export path — needs a comment or test confirming cmdlets are accessible without AddExportedCmdlet.
  3. No user-visible progress during the inline dotnet build step.

Everything else is low-risk polish.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0656e01b84

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +269 to +272
var targetDirectories = Directory.EnumerateDirectories(libRoot)
.Where(directory => !string.Equals(Path.GetFileName(directory), "Default", StringComparison.OrdinalIgnoreCase))
.ToArray();
if (targetDirectories.Length == 0) return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Build ALC loader for Default-only Lib layouts

When Lib contains only Default, this filter leaves targetDirectories empty and returns without creating any *.ModuleLoadContext.dll, but the generated ALC bootstrapper still tries to Add-Type that loader on Core import. In that layout, enabling UseAssemblyLoadContext produces a deterministic module import failure because the referenced loader DLL is never emitted.

Useful? React with 👍 / 👎.

private static string BuildAssemblyLoadContextProject(AssemblyLoadContextLoaderIdentity identity)
=> $@"<Project Sdk=""Microsoft.NET.Sdk"">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid pinning generated loader to net8.0

The temporary loader project is hardcoded to net8.0, so ALC mode produces a loader assembly that cannot be loaded by older PowerShell Core runtimes (for example, PS 7.2/7.3 on .NET 6/7), even if the module itself is otherwise compatible there. Fresh evidence in this revision is the new fixed <TargetFramework>net8.0</TargetFramework> value, which introduces host-version-coupled import failures for UseAssemblyLoadContext.

Useful? React with 👍 / 👎.

ExcludeLibraryFilter = excludeLibraryFilterFromSegments ?? spec.Build.ExcludeLibraryFilter ?? Array.Empty<string>(),
DoNotCopyLibrariesRecursively = doNotCopyLibrariesRecursivelyFromSegments ?? spec.Build.DoNotCopyLibrariesRecursively,
HandleRuntimes = handleRuntimesFromSegments ?? spec.Build.HandleRuntimes,
UseAssemblyLoadContext = useAssemblyLoadContextFromSegments ?? spec.Build.UseAssemblyLoadContext,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat UseAssemblyLoadContext as csproj-required binary intent

This commit forwards UseAssemblyLoadContext into ModuleBuildSpec, but the missing-csproj gate still does not consider that flag, so a config can explicitly request ALC loading while CsprojPath is unresolved and the pipeline will continue by reusing whatever Lib payload happens to be present. That bypasses the existing safeguard meant to fail fast on explicit binary-build settings and can silently ship stale binaries.

Useful? React with 👍 / 👎.

Comment on lines 42 to 47
if (hasLib && useAssemblyLoadContext)
BuildAssemblyLoadContextLoader(root, moduleName);

if (hasLib)
{
var librariesPath = Path.Combine(root, $"{moduleName}.Libraries.ps1");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude loader DLL from generated Libraries script

ALC mode now builds the loader DLL before generating <Module>.Libraries.ps1, and the libraries script includes every *.dll it finds under each Lib/<folder>. In layouts where Desktop resolves to Standard (for example, no Default folder), this causes the Desktop path to Add-Type the ALC loader assembly even though it is Core-only infrastructure, which can break import on Desktop hosts.

Useful? React with 👍 / 👎.

Comment on lines +287 to +291
var result = RunProcess(
"dotnet",
buildRoot,
new[] { "build", projectPath, "-c", "Release", "-o", outputRoot, "-nologo", "-v:minimal" },
AssemblyLoadContextLoaderBuildTimeout);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid requiring dotnet SDK for prebuilt-lib ALC packaging

Enabling UseAssemblyLoadContext now always shells out to dotnet build during bootstrapper generation. That introduces a new hard runtime dependency on a local .NET SDK even for module builds that intentionally use prebuilt Lib payloads with no CsprojPath, turning previously valid no-SDK packaging flows into hard failures.

Useful? React with 👍 / 👎.

@claude
Copy link
Copy Markdown

claude Bot commented May 8, 2026

Code Review: Add module-scoped ALC bootstrapper generation

Overview

This PR adds an opt-in NETAssemblyLoadContext / UseAssemblyLoadContext flag that isolates a binary module's managed dependency graph from other modules in the PowerShell session. The implementation:

  • Generates a module-scoped ModuleAssemblyLoadContext loader DLL at build time by running dotnet build in a temp directory
  • Adds a new AssemblyLoadContextBinaryLoader.Template.ps1 for the Core loading path
  • Keeps Desktop (Windows PowerShell) on the original import path
  • Also fixes a minor correctness bug in BinaryLoader.Template.ps1 (return on no-assemblies path)

The approach is architecturally sound and follows existing project conventions well. The dual naming (UseAssemblyLoadContext / NETAssemblyLoadContext) is handled consistently across the legacy adapter, segment model, cmdlet, and schema.


Issues & Suggestions

Medium Priority

1. dotnet SDK dependency with no pre-check
BuildAssemblyLoadContextLoader invokes dotnet build via subprocess at module build time. If the environment has only the .NET runtime (not SDK), or dotnet is not in PATH, the subprocess will fail after a 5-minute timeout with a cryptic error message. Consider probing dotnet --version before starting the build and surfacing a clear diagnostic like "UseAssemblyLoadContext requires the .NET SDK to be installed and available on PATH".

2. Static Contexts dictionary never clears
In the generated ModuleAssemblyLoadContext source:

private static readonly Dictionary<string, ModuleAssemblyLoadContext> Contexts = new(...);

Entries are added but never removed. Since isCollectible: false, the ALC cannot be unloaded. In development workflows where Remove-Module + Import-Module is called repeatedly, the old context instance stays referenced forever. This is expected production behavior, but could cause memory pressure during interactive development or automated test loops that reimport modules. Worth documenting in the generated source comment or the feature docs.

3. Private reflection API risk in the template

$AddExportedCmdlet = [System.Management.Automation.PSModuleInfo].GetMethod(
    'AddExportedCmdlet',
    [System.Reflection.BindingFlags]'Instance, NonPublic'
)

This is the standard community pattern for ALC-based modules and the warning fallback handles the unavailable case gracefully. However, the template comment only explains what is being done; it would be worth also noting which PowerShell versions this is known to work on (tested range), so future maintainers know when to reassess.

4. Hardcoded "net8.0" fallback will become stale

return candidates.FirstOrDefault() ?? "net8.0";

As net10.0 becomes the LTS baseline, targeting net8.0 will produce SDK warnings about targeting an EOL framework. Consider sourcing this default from a constant with a comment explaining the minimum supported ALC version, making it easy to bump later.


Minor

5. UseAssemblyLoadContext not surfaced in plan diagnostic reasons
HandleRuntimes participates in the plan validation/reasons tracking (see ModulePipelineRunner.Plan.cs lines 807–838), which means it appears in diagnostic output explaining why a .csproj is required. UseAssemblyLoadContext doesn't follow this pattern. When the feature is enabled, a user won't see it reflected in the plan's requirement reasoning. Consider adding it alongside HandleRuntimes in the reasons block.

6. Silent no-op when UseAssemblyLoadContext is set without a Lib directory

private static void BuildAssemblyLoadContextLoader(...)
{
    if (!Directory.Exists(libRoot)) return;  // silent

A misconfigured module (e.g., UseAssemblyLoadContext = true on a script-only module) silently ignores the flag. The log callback is already available here — a single log?.Invoke("UseAssemblyLoadContext is set but no Lib directory was found; skipping ALC loader generation.") would save a debugging session.

7. AssemblyLoadContextLoaderIdentity could be a record

private sealed class AssemblyLoadContextLoaderIdentity { ... }

The codebase uses records elsewhere. This class is immutable with three string properties — a record would eliminate the boilerplate constructor/properties. Not a blocker, just consistent with existing style.

8. Plan.cs: UseAssemblyLoadContext precedence could use null-coalescing

if (bl.UseAssemblyLoadContext.HasValue)
    useAssemblyLoadContextFromSegments = bl.UseAssemblyLoadContext.Value;
else if (bl.NETAssemblyLoadContext.HasValue)
    useAssemblyLoadContextFromSegments = bl.NETAssemblyLoadContext.Value;

Could be simplified to match the existing HandleRuntimes pattern or written as:

useAssemblyLoadContextFromSegments = bl.UseAssemblyLoadContext ?? bl.NETAssemblyLoadContext ?? useAssemblyLoadContextFromSegments;

Minor readability point.


What's done well

  • The framework normalization in ResolveAssemblyLoadContextTargetFramework correctly strips platform suffixes (net6.0-windowsnet6.0) and sorts .NET Framework TFMs (net472 → Version 472.0) naturally to the end of the candidate list.
  • Switching BinaryLoader.Template.ps1 to LiteralPath and adding return on the no-assemblies error path are good defensive fixes.
  • XML escaping via SecurityElement.Escape in the generated .csproj prevents injection from unusual module names.
  • Test coverage is solid: unit tests for framework resolution, an integration test that writes real files and inspects them, and a test for legacy key deserialization.
  • The EnsureBuildLibraries() guard + null-forgiving operator in BuildConfigurationFactory.cs follows the exact same pattern as HandleRuntimes, keeping the codebase consistent.

Overall this is a well-considered feature. The main asks before merging are the dotnet SDK pre-check (#1) and the silent no-op log (#6) — both are low-effort and would meaningfully improve the user-facing experience when something goes wrong.

@claude
Copy link
Copy Markdown

claude Bot commented May 8, 2026

Code Review: Add module-scoped ALC bootstrapper generation

Overall this is a well-structured feature that follows the existing patterns in the codebase. The ALC isolation approach is the right solution for PowerShell module dependency conflicts. Here is my detailed feedback.


What the PR Does

  • Adds an opt-in NETAssemblyLoadContext / UseAssemblyLoadContext switch to New-ConfigurationBuild
  • At build time, generates a module-scoped <ModuleName>.ModuleLoadContext.dll by running dotnet build against inline-generated C# source
  • A new PSM1 template (AssemblyLoadContextBinaryLoader.Template.ps1) loads the binary module through the custom ALC on PowerShell Core, while the Desktop/Windows PowerShell path continues to use direct Import-Module — preserving backward compatibility

Issues

Missing targetFrameworks in the regeneration path

ModulePipelineRunner.MergeAndTests.cs passes useAssemblyLoadContext but omits targetFrameworks:

ModuleBootstrapperGenerator.Generate(
    root,
    moduleName,
    exports,
    exportAssemblies,
    handleRuntimes,
    plan?.BuildSpec.UseAssemblyLoadContext ?? false,   // ✓
    conditionalExportDependencies);                    // targetFrameworks not passed

targetFrameworks defaults to null, so ResolveAssemblyLoadContextTargetFramework always falls back to "net8.0". If a project targets net6.0, the regenerated bootstrapper will still build and copy a net8.0 loader DLL. The main build path in ModuleBuildPipeline.cs correctly passes spec.Frameworks, so this gap only affects the manifest-regeneration code path. Worth aligning them.

Hardcoded build timeout

private static readonly TimeSpan AssemblyLoadContextLoaderBuildTimeout = TimeSpan.FromMinutes(5);

Five minutes may not be enough on a cold NuGet cache (first run, CI with no package cache). The main dotnet build for the user's project presumably has no hard timeout. Consider exposing this via a parameter or at least a generous default (10 min), or logging a progress hint so users don't think the process has hung.

Static Contexts dictionary — module-update scenario

private static readonly Dictionary<string, ModuleAssemblyLoadContext> Contexts = new(...);

The dictionary is keyed by the full assembly path and the ALC is isCollectible: false. If a user updates a module in-session (replaces the DLL on disk) and re-imports, the cached ALC entry points to the old module. This is a known limitation of non-collectible ALCs in PowerShell, but it would be worth adding a code comment so future maintainers understand why re-import may not pick up a new version without restarting the process.


Suggestions

Lock scope in LoadModule

lock (Sync)
{
    if (!Contexts.TryGetValue(fullPath, out var context))
    {
        context = new ModuleAssemblyLoadContext(...);
        Contexts[fullPath] = context;
    }
    return context.LoadMainModule();   // ← inside the lock
}

LoadMainModule() only assigns _moduleAssembly ??= LoadFromAssemblyPath(...), a one-time disk read. Holding the global lock for it means all other callers (loading different modules) are serialized. Two options: (a) accept it — this is a one-time startup cost; or (b) use a per-context lock so independent module loads do not block each other. Either is defensible; a comment explaining the choice would help.

Duplicate fallback logic in LegacySegmentAdapter.cs

The UseAssemblyLoadContext / NETAssemblyLoadContext priority ternary appears in two places (CollectFromLegacyConfiguration and AddLegacySegmentDictionary). A small private helper would remove the duplication:

private static bool? GetAssemblyLoadContext(IDictionary dict) =>
    HasKey(dict, "UseAssemblyLoadContext") ? GetBool(dict, "UseAssemblyLoadContext")
    : HasKey(dict, "NETAssemblyLoadContext") ? GetBool(dict, "NETAssemblyLoadContext")
    : null;

Integration test needs dotnet on PATH

Generate_WithAssemblyLoadContext_WritesAlcBootstrapperAndKeepsDesktopLibrariesScript calls dotnet build via RunProcess. It is correctly annotated with [Trait("Category", "Integration")] and the PR validation instructions call it out explicitly — this is fine as-is, just noting that CI must have the SDK available for this trait to pass.


Positive Notes

  • Bug fix bundled in: switching BinaryLoader.Template.ps1 to -LiteralPath and adding the missing return after Write-Error are both correct fixes included as a natural improvement alongside the feature.
  • New template is consistent: AssemblyLoadContextBinaryLoader.Template.ps1 starts with -LiteralPath and includes the return, so it doesn't repeat the old bugs.
  • AddExportedCmdlet reflection with graceful fallback: using PSModuleInfo.AddExportedCmdlet via reflection is the established community pattern; the Write-Warning fallback for unknown PowerShell versions is the right defensive approach.
  • NETAssemblyLoadContext alias handled in schema, legacy adapter, and model: the aliasing strategy is thorough — both the PowerShell parameter alias and the JSON/hashtable legacy key are handled consistently.
  • Test coverage: ResolveAssemblyLoadContextTargetFramework unit tests cover the key selection logic cleanly.

Summary

The main actionable item is passing targetFrameworks through the manifest-regeneration code path. The static-context limitation and lock scope are lower-priority design notes rather than bugs. Everything else is in good shape.

@claude
Copy link
Copy Markdown

claude Bot commented May 8, 2026

Code Review — PR #354: Add module-scoped ALC bootstrapper generation

Overview

This PR adds an opt-in NETAssemblyLoadContext / UseAssemblyLoadContext build setting for binary modules. When enabled, it:

  1. Generates a module-scoped <ModuleName>.ModuleLoadContext.dll at build time via a spawned dotnet build process
  2. Substitutes a new PS1 template (AssemblyLoadContextBinaryLoader.Template.ps1) that loads the binary DLL through that ALC on PowerShell Core
  3. Keeps .NET Framework / Desktop on the existing import path
  4. Excludes the generated loader DLL from the Libraries.ps1 dependency enumeration

Architecture and layer placement are correct — core logic lives in PowerForge/Services/ModuleBootstrapperGenerator.cs, the cmdlet only binds parameters, and services handle everything else. This matches the layering rules in AGENTS.md.


Potential Issues

1. reasons.Add("NETAssemblyLoadContext") uses the legacy name as the canonical reason string

In ModulePipelineRunner.Plan.cs line ~843:

if (effectiveUseAssemblyLoadContext)
    reasons.Add("NETAssemblyLoadContext");

This means even when the user writes UseAssemblyLoadContext: true in JSON or -NETAssemblyLoadContext via cmdlet, the reason string in the plan is "NETAssemblyLoadContext". The companion test (Plan_DeserializesLegacyNetAssemblyLoadContextAlias_IntoBuildSpec) asserts this exact string, so it's tested, but it's slightly confusing for diagnostics. Consider either normalising to UseAssemblyLoadContext or documenting the intentional aliasing.

2. AddExportedCmdlet private reflection

$AddExportedCmdlet = [System.Management.Automation.PSModuleInfo].GetMethod(
    'AddExportedCmdlet',
    [System.Reflection.BindingFlags]'Instance, NonPublic'
)

This relies on a private internal API that could be removed in a future PowerShell version. The template does have a $null guard + Write-Warning fallback, which is good defensive practice. The comment correctly notes this follows patterns used by community ALC loaders. That said, it is worth adding a note to the README or release notes so consumers know this is a known limitation.

3. Blocking async with .GetAwaiter().GetResult()

private static ProcessRunResult RunProcess(...) =>
    new ProcessRunner().RunAsync(...).GetAwaiter().GetResult();

Synchronously blocking on a Task is generally fine in PowerShell Core (no custom SynchronizationContext), but it can deadlock in hosts that do set one. This pattern likely exists elsewhere in the project so it probably isn't new risk, but is worth flagging.

4. Hardcoded 1.0.0.0 in the generated loader project

<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<InformationalVersion>1.0.0</InformationalVersion>

The loader version is not linked to the parent module version. This is fine functionally (the loader is an opaque build artifact), but could be surprising when inspecting file properties in the installed module directory. Passing through the module's version would make tracing easier.

5. Missing null check before assemblyLoadContextLoaderIdentity in Generate

if (hasLib && useAssemblyLoadContext && assemblyLoadContextLoaderIdentity is not null)
    BuildAssemblyLoadContextLoader(root, assemblyLoadContextLoaderIdentity, ...);

The is not null guard is redundant because assemblyLoadContextLoaderIdentity is always non-null when useAssemblyLoadContext == true (see CreateAssemblyLoadContextLoaderIdentity). This isn't a bug, but the double condition slightly obscures the intent.

6. ResolveAssemblyLoadContextTargetDirectories only copies to one directory

if (byName.TryGetValue("Standard", out var standard))
    return new[] { standard };
if (byName.TryGetValue("Core", out var core))
    return new[] { core };

The single-directory result is intentional and matches the template's $LibFolder resolution at runtime — the logic is consistent. However, there's no test for the three-folder case (Standard + Core + Default), which would be a common module layout. A short unit test asserting that only Standard is returned when all three exist would guard this invariant cheaply.


Minor Observations

  • BinaryLoader.Template.ps1 — missing return added: The addition of return after Write-Error 'No assemblies found' is a correct bug fix — previously the script would fall through with $Framework unset.
  • -LiteralPath change in BinaryLoader.Template.ps1: Switching from -Path $PSScriptRoot\Lib to -LiteralPath $LibRoot is a defensive improvement that avoids issues with paths containing wildcard characters.
  • Dual-alias pattern is consistent: The UseAssemblyLoadContext / NETAssemblyLoadContext alias handling in LegacySegmentAdapter, ConfigurationBuildLibrariesSegment, and Plan() follows the same pattern already used for ExcludeLibraryFilter / NETExcludeLibraryFilter. No concern here.
  • TryGetNetTfmVersion correctness: net472 correctly falls through (substring "472" fails Version.TryParse without a dot), and netstandard2.0 is explicitly excluded. The fallback to net8.0 as the default LTS baseline is a sensible choice.

Test Coverage

Tests are solid for the main paths:

  • ResolveAssemblyLoadContextTargetFramework_UsesLowestModernModuleFramework
  • ResolveAssemblyLoadContextTargetFramework_DefaultsToNet8WhenNoModernFrameworkIsKnown
  • Generate_WithAssemblyLoadContext_WritesAlcBootstrapperAndKeepsDesktopLibrariesScript
  • Generate_WithAssemblyLoadContextAndDefaultOnlyLib_WritesLoaderBesideDefaultAssembly
  • Plan_DeserializesLegacyNetAssemblyLoadContextAlias_IntoBuildSpec

Suggested additions:

  • A test for ResolveAssemblyLoadContextTargetDirectories with Standard + Core + Default all present (assert only Standard is returned)
  • A test asserting net5.0 (EOL but syntactically valid) resolves correctly (would return net5.0, not the default)
  • A test asserting the loader DLL is excluded from Libraries.ps1 content when useAssemblyLoadContext = true (the existing integration test checks DoesNotContain on the file, which covers this)

Summary

The implementation is well-structured, follows project layering rules, and solves a real problem (ALC isolation for binary modules). The core logic is correct and the test coverage is meaningful. The main actionable items are:

  1. Consider normalising reasons.Add("NETAssemblyLoadContext") to the canonical name UseAssemblyLoadContext
  2. Add a test for the three-folder Lib layout in ResolveAssemblyLoadContextTargetDirectories
  3. Document the private AddExportedCmdlet reflection dependency in release notes

Everything else is minor or informational. Good PR overall.

@claude
Copy link
Copy Markdown

claude Bot commented May 8, 2026

Code Review — PR #354: Add module-scoped ALC bootstrapper generation

Overview

This PR adds an opt-in UseAssemblyLoadContext / NETAssemblyLoadContext build flag that generates a module-scoped AssemblyLoadContext (ALC) loader DLL ({ModuleName}.ModuleLoadContext.dll) and a matching bootstrapper template that loads binary modules through it on PowerShell Core. Desktop/Windows PowerShell is unchanged. The approach is sound and the default-off design protects existing users.


Bugs / Risks

1. Silent cmdlet-export failure when AddExportedCmdlet reflection disappears

The generated template warns but does NOT throw when the private method is missing:

} else {
    Write-Warning -Message "AddExportedCmdlet is not available on this PowerShell version; cmdlets from $LibraryName may not be exported."
}

If a future PowerShell version removes this internal method, the module will load successfully but export zero cmdlets — no visible error, just silent failure. Users would see CommandNotFoundException later with no obvious cause. Consider making this a terminating error, or at minimum documenting the behavior contract somewhere accessible to consumers of the generated module.

2. dotnet build spawned inside code generation

BuildAssemblyLoadContextLoader shells out to dotnet build mid-generation. This introduces:

  • A hard dependency on .NET SDK being installed and on PATH (the EnsureDotNetSdkAvailable check is good, but the error surfaces late — after project writing, not before).
  • An implicit NuGet restore / network dependency on the first build.
  • On slow machines or saturated networks, the 5-minute timeout (AssemblyLoadContextLoaderBuildTimeout) may not be sufficient.

Moving the SDK availability check to before any temp directory/file I/O would give a cleaner failure:

EnsureDotNetSdkAvailable(/* any working directory */);
// ... then create build artifacts

3. GetAwaiter().GetResult() on async process runner

private static ProcessRunResult RunProcess(...)
    => new ProcessRunner()
        .RunAsync(...)
        .GetAwaiter()
        .GetResult();

Blocking on an async method via .GetResult() can deadlock if ProcessRunner.RunAsync ever captures a synchronization context (e.g., when run inside a PowerShell pipeline host with a custom SyncContext). Consider making Generate async or ensuring RunAsync uses ConfigureAwait(false) throughout.


Design / Minor Issues

4. Loader identity computed twice

CreateAssemblyLoadContextLoaderIdentity(moduleName) is called independently in both Generate and BuildBootstrapperPsm1. Since it is pure, this is not a correctness issue, but passing the identity as a parameter would make the coupling explicit and save the allocation.

5. LoadMainModule thread-safety relies on implicit outer lock

private Assembly LoadMainModule()
{
    _moduleAssembly ??= LoadFromAssemblyPath(_moduleAssemblyPath);
    return _moduleAssembly;
}

This is only safe because LoadModule calls it while still holding lock (Sync). A future reader of LoadMainModule in isolation will not see the invariant. A brief comment (e.g. // always called under Sync) or a Debug.Assert(Monitor.IsEntered(Sync)) would protect this assumption.

6. Temp parent directories are never cleaned up

buildRoot is %TEMP%/PowerForge/module-load-context/{name}_{guid} and is deleted in finally. The parent PowerForge/module-load-context/ directory accumulates empty shells over repeated builds and is never pruned. Minor, but worth a one-line comment or periodic cleanup.


Positive Observations

  • Correct ALC pattern: the generated ModuleAssemblyLoadContext uses AssemblyDependencyResolver with a directory-scan fallback and correctly returns null for unknown assemblies to let the default context handle shared PowerShell APIs. The non-collectible-by-design choice with a static Contexts dictionary is coherent with the "per-path singleton" semantics.
  • Good backward compatibility: legacy key NETAssemblyLoadContext deserializes through both the LegacySegmentAdapter and the JSON model, and the Plan method gives UseAssemblyLoadContext priority when both are present — clean alias story.
  • Pre-existing BinaryLoader.Template.ps1 bug fixed: the missing return after Write-Error and the switch to -LiteralPath are real correctness improvements bundled in.
  • Schema updated: both UseAssemblyLoadContext and NETAssemblyLoadContext are added to powerforge.segments.schema.json.
  • Reasonable test coverage: unit tests for framework resolution, directory resolution, and the factory mapping; integration tests for Core+Default layout, Default-only layout, and JSON deserialization of the legacy alias — all key paths are covered.
  • CHANGELOG updated in the Unreleased section, following project conventions.

Summary

The overall implementation is solid. The three main things worth addressing before merge are: (1) the silent-no-export risk from the private reflection fallback, (2) moving the SDK availability check earlier in the flow, and (3) clarifying the LoadMainModule thread-safety invariant in the generated source. The duplicate identity computation and temp directory accumulation are minor cleanup items that can be deferred.

@claude
Copy link
Copy Markdown

claude Bot commented May 8, 2026

Code Review – PR #354: Add module-scoped ALC bootstrapper generation

Overall: A well-structured, opt-in feature that follows the project's layering guidelines closely. Business logic is correctly placed in PowerForge (not the cmdlet layer), dual-key backward compatibility is consistent, the JSON schema is updated, and test coverage is solid for all testable paths. The concerns below are actionable but none are blockers except items 1 and 3.


What the PR does

  • Adds NETAssemblyLoadContext / UseAssemblyLoadContext opt-in flag for binary modules.
  • On activation, generates a tiny per-module dotnet build project that produces <ModuleName>.ModuleLoadContext.dll at bootstrapper generation time.
  • The new AssemblyLoadContextBinaryLoader.Template.ps1 loads the binary module into a private AssemblyLoadContext on PowerShell Core, keeping the module's dependency graph isolated from other modules in the same session.
  • Desktop (Windows PowerShell) falls through to the existing Libraries.ps1 path unchanged.
  • Also fixes a pre-existing missing-return bug in BinaryLoader.Template.ps1 and upgrades its Get-ChildItem -Path to -LiteralPath.

Issues / Suggestions

🔴 1 – Hard failure when AddExportedCmdlet is removed from PowerShell

# AssemblyLoadContextBinaryLoader.Template.ps1 ~line 44-48
if ($null -ne $AddExportedCmdlet) { ... }
else {
    throw [System.NotSupportedException]::new("AddExportedCmdlet is not available …")
}

This throw will break all ALC-loaded modules on every user's machine the moment a future PowerShell release removes the internal method. The CHANGELOG notes this, but a warning + graceful fallback to a regular Import-Module would be far safer:

else {
    Write-Warning "AddExportedCmdlet is unavailable on this PowerShell version. Falling back to direct Import-Module; cmdlets will load from the default context."
    & $ImportModule $ModuleAssemblyPath -ErrorAction Stop
}

🔴 2 – Blocking async call inside a potential PowerShell runspace (RunProcess)

// ModuleBootstrapperGenerator.cs
private static ProcessRunResult RunProcess(...)
    => new ProcessRunner()
        .RunAsync(...)
        .GetAwaiter()
        .GetResult();   // ← can deadlock in PS runspace sync context

.GetAwaiter().GetResult() on an async method can deadlock when called from a thread with a captured SynchronizationContext (common in PowerShell runspaces and WPF hosts). If ProcessRunner is already synchronous-capable, expose that path directly; otherwise use Task.Run(...).GetAwaiter().GetResult() to escape the sync context.


🟡 3 – Non-collectible ALC makes Import-Module -Force silently ineffective for Core

The generated ModuleAssemblyLoadContext is isCollectible: false (intentional), meaning Import-Module -Force from the user's perspective will re-run the script wrapper but the underlying binary module DLL is never actually reloaded. Developers who iterate on their binary module will be confused.

The template comment explains the limitation at code level, but the user-visible error surface is a stale binary module with no warning. Consider emitting a Write-Warning during Force-reload if the context already exists, or document this in the module's generated help/README.


🟡 4 – ToCSharpIdentifier silently drops the first character when it is a digit

// i == 0 path: only Letter or '_' is valid
var valid = i == 0 ? char.IsLetter(ch) || ch == '_' : ...;
sb.Append(valid ? ch : '_');   // digit at position 0 → '_', digit is dropped

"1Module""_odule" — the 1 is replaced by _, not prepended with _. Module names starting with digits are rare in practice, but the function could prefix with _ explicitly:

if (i == 0 && char.IsDigit(ch)) sb.Append('_');
sb.Append(valid ? ch : '_');

🟡 5 – AssemblyLoadContextLoaderBuildTimeout (10 minutes) is excessive

The generated .csproj is a trivial single-file project; dotnet build on it should finish in under 60 seconds on any machine with a warm SDK. A 10-minute timeout delays the user's feedback loop significantly if NuGet is broken or the SDK is unavailable. 90 seconds would be a more appropriate default.


🟡 6 – CreateAssemblyLoadContextLoaderIdentity called twice per generate

Generate calls it on line ~46 and BuildBootstrapperPsm1 calls it again internally on line ~239. Since the method is a pure function the result is identical, but it adds unnecessary cognitive load. The identity value should be threaded through as a parameter rather than recomputed.


🟢 Minor / Nits

  • BinaryLoader.Template.ps1 fix (missing return after Write-Error) is a good catch. Consider calling it out as a standalone fix in the changelog so it is discoverable independently of the ALC feature.
  • EnsureDotNetSdkAvailable error message could suggest a minimum SDK version requirement (e.g., .NET 6 SDK minimum) since ALC requires AssemblyLoadContext which is .NET Core/.NET 5+ only.
  • Path.GetFullPath case sensitivity on Linux: The static Contexts dictionary uses OrdinalIgnoreCase but Path.GetFullPath on Linux is case-sensitive, so two references to the same file with different casing would produce two separate contexts. Very unlikely in practice, but noting it for completeness.

Test coverage assessment

Area Covered
ResolveAssemblyLoadContextTargetFramework
ResolveAssemblyLoadContextTargetDirectories
Factory mapping of new flag
Legacy alias deserialization in Plan
Generator output with Core/Default layouts
EnsureDotNetSdkAvailable / actual dotnet build ❌ (reasonable exclusion)
SanitizeAssemblyName / ToCSharpIdentifier edge cases ❌ (low risk)

Summary

The architecture is solid and the layering follows AGENTS.md conventions correctly. The two items worth fixing before merge are:

  1. The hard-throw fallback for AddExportedCmdlet (should warn + gracefully fall back), and
  2. The .GetAwaiter().GetResult() call site (deadlock risk in PS runspace).

The remaining items are improvements that can be addressed in follow-up PRs.

@claude
Copy link
Copy Markdown

claude Bot commented May 8, 2026

Code Review — PR #354: Add module-scoped ALC bootstrapper generation

Overview

This PR adds an opt-in UseAssemblyLoadContext / NETAssemblyLoadContext setting that generates a per-module AssemblyLoadContext loader DLL, isolating a binary module's managed dependencies from other modules in the PS session. The approach — generating a custom AssemblyLoadContext subclass, building it on-demand via dotnet build, placing it next to the module's Core DLLs, and using a private PSModuleInfo.AddExportedCmdlet reflection hook to re-export cmdlets — is technically sound and follows established community practice for ALC-based PS module loading.

The implementation is backward-compatible (opt-in, legacy alias handled in both the JSON adapter and the plan accumulator) and the test coverage is meaningfully expanded.


Issues and Suggestions

1. Sync-over-async in RunProcess (ModuleBootstrapperGenerator.cs)

private static ProcessRunResult RunProcess(...)
    => Task.Run(() => new ProcessRunner().RunAsync(new ProcessRunRequest(..., timeout)))
        .GetAwaiter()
        .GetResult();

Task.Run here is unnecessary. ProcessRunner.RunAsync has no synchronization-context affinity that would cause a deadlock, and Task.Run adds a spurious thread-pool hop. The simpler and clearer pattern is:

private static ProcessRunResult RunProcess(...)
    => new ProcessRunner().RunAsync(new ProcessRunRequest(..., timeout)).GetAwaiter().GetResult();

If the intent was to escape a potential synchronization context, that reasoning should be documented and ConfigureAwait(false) applied instead.

2. CreateAssemblyLoadContextLoaderIdentity is called twice

In Generate(), assemblyLoadContextLoaderIdentity is computed and passed to BuildLibrariesScript. Then, inside BuildBootstrapperPsm1, a second identity object (loaderIdentity) is independently recomputed from the same moduleName. Both calls are deterministic, so correctness is not affected, but the duplication is confusing and wasteful. Consider passing assemblyLoadContextLoaderIdentity from Generate through to BuildBootstrapperPsm1 directly.

3. Non-collectible ALC semantics not surfaced to users

The generated ModuleAssemblyLoadContext is non-collectible:

// Module contexts are intentionally non-collectible. A process restart is required to load a replaced DLL at the same path.
private static readonly Dictionary<string, ModuleAssemblyLoadContext> Contexts = ...

This is the correct design choice, but it has an important user-visible consequence: Import-Module -Force will not reload the binary module from disk — the ALC-held assembly is permanent for the process lifetime. This should be mentioned in the XML doc on UseAssemblyLoadContext in ModuleBuildSpec.cs and/or the CHANGELOG.MD entry so module authors who iterate on hot-reloads are not surprised.

4. ResolveAssemblyLoadContextTargetDirectories always returns 0 or 1 directories

The return type and method name (...Directories, plural) imply multiple values, but the logic exits after the first match (Standard → Core → Default). The test asserts new[] { standard } (single element). Consider either renaming to ResolveAssemblyLoadContextTargetDirectory (returning string?) or documenting why an array is retained (e.g., reserved for future multi-target support).

5. Framework-detection block is duplicated between templates

AssemblyLoadContextBinaryLoader.Template.ps1 reproduces the full $Default/$Core/$Standard if-chain from BinaryLoader.Template.ps1 verbatim. A divergence or future fix applied to one will silently miss the other. If templating allows partial includes or a shared helper .ps1 dot-sourced by both, that would be preferable. If not feasible, at minimum add a comment like # Kept in sync with BinaryLoader.Template.ps1 so reviewers know to look for it.

6. Side-fix included (BinaryLoader.Template.ps1)

The return added after Write-Error 'No assemblies found' and the -Path-LiteralPath change are correct bug fixes. They're unrelated to the ALC feature, so a mention in the changelog or commit message would help bisect if needed — but not blocking.

7. EnsureDotNetSdkAvailable swallows non-zero exit but not the dotnet --version output

The method throws when dotnet cannot be launched at all, and also when ExitCode != 0. However, dotnet --version will return non-zero in the rare case of a corrupted SDK install without a useful StdErr message. Consider also surfacing result.StdOut in the error message (in addition to StdErr) since dotnet --version writes to stdout.


Positive Observations

  • Backward compatibility is thorough. Legacy NETAssemblyLoadContext alias is handled in the JSON segment model, the legacy hashtable adapter, and the plan accumulator. All three paths are tested.
  • Template variable escaping is correct. EscapeXml is applied to project file interpolation; C# identifiers are sanitized via ToCSharpIdentifierPath / SanitizeAssemblyName before being embedded in the generated source.
  • Thread safety of the generated ALC is correct. LoadModule holds Sync for context creation and the first LoadFromAssemblyPath; the Load override runs outside the lock (correct for AssemblyLoadContext virtual dispatch).
  • Fallback path is clean. When AddExportedCmdlet is unavailable the bootstrapper issues a Write-Warning and falls back to a direct Import-Module, which keeps the module usable even if a future PowerShell removes the private method.
  • Test quality is good. The [Trait("Category", "Integration")] annotation on real-filesystem tests is a nice practice.

Summary

No blocking issues. The items above are improvements in clarity, defensive documentation, and minor design tidiness rather than correctness bugs. The ALC approach is architecturally sound, the code follows project conventions, and the test coverage is solid.

@PrzemyslawKlys PrzemyslawKlys merged commit 2793720 into main May 8, 2026
6 checks passed
@PrzemyslawKlys PrzemyslawKlys deleted the codex/module-scoped-alc-loader branch May 8, 2026 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant