Search code examples
c#.netpowershell.net-assembly

Can a type be added that references an in-memory assembly?


The following code creates an in-memory type, finds its assembly, then attempts to add another type referencing the first:

Add-Type -TypeDefinition 'namespace MyNamespace { public class c {}}'

$assembly =
  $([System.AppDomain]::CurrentDomain.
    GetAssemblies().
    GetTypes()                          |
    ? {$_.Namespace -eq 'MyNamespace' } |
    % Assembly                          |
    Select-Object -Unique -First 1       )

Add-Type -TypeDefinition 'namespace MyNamespace {public class d : c {}}' `
    -ReferencedAssemblies $assembly

The attempt fails with error

Add-Type: C:\repro.ps1:12
Line |
  12 |  Add-Type -TypeDefinition 'namespace MyNamespace {public class d : c { …
     |  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | The value cannot be an empty string. (Parameter 'path')

which suggests Add-Type is looking for a path that probably doesn't exist since the assembly only exists in-memory.

Can a type be added that references an in-memory-only type?

What works

The following approach works but requires the assemblies to exist in the filesystem:

Remove-Item .\c.dll,.\d.dll -ErrorAction SilentlyContinue
Add-Type `
    -TypeDefinition 'namespace MyNamespace { public class c {}}' `
    -OutputAssembly .\c.dll
Add-Type -Path .\c.dll
[MyNamespace.c]::new()

Add-Type -TypeDefinition 'namespace MyNamespace {public class d : c {}}' `
    -ReferencedAssemblies .\c.dll `
    -OutputAssembly       .\d.dll

Add-Type -Path .\d.dll
[MyNamespace.d]::new()

Solution

  • The answer is implied by your own findings in the question, but let me spell it out:

    As of PowerShell (Core) 7 v7.4.x:

    Indeed, while Add-Type's -ReferencedAssemblies parameter does accept [System.Reflection.Assembly] instances (which stringify to their .FullName property in the context of binding to this [string[]]-typed parameter) (as an alternative to passing assembly file paths), such instances are only recognized if they represent an on-disk assembly, i.e. one whose .Location property reports a file-system path.

    Since Add-Type -TypeDefinition calls produce in-memory assemblies in the absence of an -OutputAssembly argument, such assemblies cannot be used as -ReferencedAssemblies arguments in later Add-Type calls.

    • It seems that this constraint - which isn't currently documented - is imposed by PowerShell, not by the underlying .NET type implementing the C# compiler, which is called in-process from PowerShell.[1]

    • Therefore, it is conceivable that a future version of PowerShell 7 will lift this constraint - which hinges on someone stepping up to file an issue to that effect in the GitHub repository


    Thus, the solution for now is indeed to create on-disk assemblies for types that must be referenced in later Add-Type calls:

    Doing so is cumbersome:

    • Add-Type invariably fails if the target file passed to -OutputAssembly already exists (there is no -Force switch).

    • While using the -PassThru switch is generally required when using -OutputAssembly in order to also load a generated on-disk assembly into the current session, it is inexplicably also needed to ensure that a later Add-Type call that uses an in-memory assembly that builds on the on-disk assembly via -ReferencedAssemblies actually surfaces the generated in-memory-assembly types in the current session.[2]

      • This smells like a bug: a type generated in an in-memory assembly should invariably be loaded into the current session, along with any on-disk assemblies it depends on, given that not loading an in-memory assembly into the current session is obviously pointless (it will disappear when the session exits).
    • On Windows, you cannot ensure that the on-disk assembly is deleted on exiting your session, because it is still locked (works fine in Unix-like environments).

    if ('MyNamespace.D' -as [type]) {
      Write-Verbose -Verbose 'Already loaded.'
    }
    else {
    
      # Note: New-TemporaryFile works in principle, but immediately *creates* the
      #       file, which would cause Add-Type to fail (it has no -Force switch)
      $tempFile = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName())
    
      # Note: -PassThru ensures that the generated assembly is also loaded into
      #       the current session, which is seemingly a prerequisite for the 
      #       type created by the later, in-memory-assembly Add-Type call to actually
      #       surface in the current session.
      $null =
        Add-Type -ErrorAction Stop -PassThru -OutputAssembly $tempFile -TypeDefinition @'
          namespace MyNamespace { public class C {} }
    '@
    
      # Pass the file path of the generated on-disk assembly to -ReferencedAssemblies
      try {
        Add-Type -ErrorAction Stop -ReferencedAssemblies $tempFile -TypeDefinition @'
        namespace MyNamespace { public class D : C {} }
    '@
    
      }
      finally {
        Remove-Item -ErrorAction Ignore -LiteralPath $tempFile -Force
        if (-not $?) { # !! This invariably happens on Windows.
          Write-Warning "Failed to remove temp. assembly '$tempFile' in-session."
        }
      }
    }
    
    # Output the compiled types.
    [MyNamespace.C]
    [MyNamespace.D]
    

    [1] The type that implements the C# compiler is Microsoft.CodeAnalysis.CSharp.CSharpCompilation and its .Create() method's references parameter expects Microsoft.CodeAnalysis.MetadataReference instances, which can be obtained from in-memory assemblies, e.g. via this constructor overload.

    [2] Here's a minimal repro:
    $tempDll = Join-Path (Convert-Path temp:) throwaway_$PID.dll; if (Test-Path $tempDll) { Remove-Item -ErrorAction Stop $tempDll }; $null = Add-Type -OutputAssembly $tempDll -TypeDefinition 'namespace MyNamespace { public class C {} }'; Add-Type -ReferencedAssemblies $tempDll -TypeDefinition 'namespace MyNamespace { public class D : C {} }'; [MyNamespace.D]; Remove-Item -ErrorAction Ignore $tempDll || Write-Warning "Failed to delete $tempDll"
    Unless you add -PassThru to the first Add-Type call - be sure to start a new session first - type [MyNamespace.D] won't be available (and neither will [MyNamespace.C]).