Skip to content

Classes: after the compiled-script cache is cleared (> 1024 scripts), Import-Module -Force creates new class types, but the TypeCache keeps the old ones, so generic types of the same class no longer match #28122

Description

Prerequisites

Steps to reproduce

A module has a class in a dot-sourced Class.ps1 and functions in a dot-sourced
Functions.ps1. A function parameter has the type [System.Collections.Generic.List[Item]].
No file changes during the test. Save this as repro.ps1 and run
pwsh -NoProfile -File repro.ps1:

param([int] $Count = 1100)

# A two-file module: one class file and one function file, dot-sourced by the .psm1.
$dir = Join-Path ([IO.Path]::GetTempPath()) "ClassRepro-$PID"
New-Item -ItemType Directory -Force $dir | Out-Null
Set-Content "$dir/Class.ps1" 'class Item { [string] $Name }'
Set-Content "$dir/Functions.ps1" @'
function Add-Item {
    param([System.Collections.Generic.List[Item]] $List, [string] $Name)
    $List.Add([Item]@{ Name = $Name })
}
function New-ItemList {
    $list = [System.Collections.Generic.List[Item]]::new()
    Add-Item -List $list -Name 'a'
    $list
}
'@
Set-Content "$dir/ClassRepro.psm1" @'
. $PSScriptRoot/Class.ps1
. $PSScriptRoot/Functions.ps1
Export-ModuleMember -Function Add-Item, New-ItemList
'@

Import-Module "$dir/ClassRepro.psm1" -Force
$old = & (Get-Module ClassRepro) { [Item] }
"1st import: New-ItemList -> $((New-ItemList).Count) item(s)"

# Compile more than 1024 distinct scripts (a long test run does the same).
for ($i = 0; $i -lt $Count; $i++) { $null = [scriptblock]::Create("`$null = $i") }

Import-Module "$dir/ClassRepro.psm1" -Force
$new = & (Get-Module ClassRepro) { [Item] }
"[Item] after re-import is a new type: $($new -ne $old)"
try { "2nd import: New-ItemList -> $((New-ItemList).Count) item(s)" }
catch { "2nd import: New-ItemList FAILED: $($_.Exception.Message)" }
try {
    $list = [System.Collections.Generic.List`1].MakeGenericType($new)::new()
    Add-Item -List $list -Name 'b'
    "2nd import: Add-Item with List[new Item] -> OK"
}
catch { "2nd import: Add-Item with List[new Item] FAILED: $($_.Exception.Message)" }
Remove-Item -Recurse -Force $dir

Run it a second time with -Count 0 (no flood) for comparison.

What we found (mechanism)

Two process-wide caches get out of step:

  1. CompiledScriptBlock.s_cachedScripts holds compiled scripts keyed by (path, content). When it
    holds more than 1024 entries, it is cleared completely. While the entry for Class.ps1 is
    cached, Import-Module -Force re-runs the same compiled script, and [Item] keeps its type.
    After the clear, the re-import compiles Class.ps1 again, and a second
    PowerShell Class Assembly (Version 1.0.0.2) defines a new Item.
  2. System.Management.Automation.Language.TypeCache.s_cache maps (type name,
    TypeResolutionState) to a resolved type. Nothing clears it. It still maps Item and
    System.Collections.Generic.List[Item] to the 1.0.0.1 types. Functions.ps1 is compiled again
    after the re-import, and its [List[Item]] resolves through this cache to List<Item v1>.
    [Item]@{...} resolves to the new Item v2. The two do not fit.

Evidence:

  • After the re-import, the -List parameter type of Add-Item has the OLD Item as its generic
    argument. With -Count 0, it has the only Item.
  • If the TypeCache entries whose name contains Item are removed (by reflection) before the
    re-import, the repro passes: the parameter type then uses the new Item.
  • If the function is in the .psm1 itself instead of a dot-sourced file, the repro passes.
  • Inconsistent resolution of ambiguous custom classes in constructors vs generic type arguments #20893 shows the same TypeCache behaviour for a user redefinition: generic arguments resolve to
    the first definition, [User] to the newest one.

Bug or by design?

The documentation says (about_Classes, Limitations): "PowerShell classes can't be unloaded or
reloaded in a session. Workaround: Start a new session." and "there's no way to load any updated
classes." #20893 and #21151 were closed on that basis, because in those issues the user changed
or redefined the class.

We think this case goes beyond the documented limitation:

  • Nothing is reloaded or changed. The source is identical. The user does not ask for new class
    types.
  • For identical source, Import-Module -Force normally keeps the class types (they are not
    reloaded, as documented). This case breaks that normal behaviour: the class types change, and
    PowerShell does not report it.
  • It is non-deterministic from the user's view. It depends on how many distinct scripts the
    process compiled since the last import (here about 1024), a number that the user cannot see or
    control. The same Import-Module -Force works early in a session and fails late in it.
  • The result is a split, not a clean reload: in the same compiled script, [Item] is the new
    type and [List[Item]] is the old type. No documented behaviour gives two different Item
    types in one statement.
  • It occurs in normal use: a large Pester run re-imports the module under test in many test
    files (Import-Module -Force in BeforeAll) and compiles thousands of scriptblocks. The
    failures then occur only in the late test files and only in full runs.

Possible fixes (for discussion):

  • When a script whose AST defines types is compiled again, remove from the TypeCache the entries
    that point to types of the replaced dynamic assembly, or key those entries by the defining
    assembly.
  • Or do not evict, from s_cachedScripts, entries whose scripts define types, so that the class
    identity stays stable for identical source (the same as before the eviction).
  • At minimum, document that class identity depends on the script-block cache.

Workaround

Keep a reference to the compiled class scriptblocks at first import, and on each re-import run
those kept scriptblocks again instead of dot-sourcing the class files. Then the class types stay
the same for the life of the process.

(Tested on 7.6.5 only. #20893 shows the TypeCache half on 7.4.0.)

Expected behavior

The second `Import-Module -Force` behaves the same whether or not other scripts ran before it. With
`-Count 0` this is the output:


1st import: New-ItemList -> 1 item(s)
[Item] after re-import is a new type: False
2nd import: New-ItemList -> 1 item(s)
2nd import: Add-Item with List[new Item] -> OK


If the re-import does make a new `[Item]` type, then `[List[Item]]` in code compiled after the
re-import must be built on that same new type.

Actual behavior

With the default `-Count 1100`:


1st import: New-ItemList -> 1 item(s)
[Item] after re-import is a new type: True
2nd import: New-ItemList FAILED: Cannot find an overload for "Add" and the argument count: "1".
2nd import: Add-Item with List[new Item] FAILED: Cannot process argument transformation on parameter 'List'. Cannot convert the "System.Collections.Generic.List`1[Item]" value of type "System.Collections.Generic.List`1[[Item, PowerShell Class Assembly, Version=1.0.0.2, Culture=neutral, PublicKeyToken=null]]" to type "Item".


The class file did not change. The module source did not change. Only the number of scripts that
the process compiled before the re-import is different. In a sweep, `-Count 1020` passes and
`-Count 1030` fails.

Error details

System.Management.Automation.MethodException: Cannot find an overload for "Add" and the argument count: "1".
At <temp>\ClassRepro-<pid>\Functions.ps1:3 char:5
+     $List.Add([Item]@{ Name = $Name })
+     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

System.Management.Automation.ParameterBindingArgumentTransformationException: Cannot process argument transformation on parameter 'List'. Cannot convert the "System.Collections.Generic.List`1[Item]" value of type "System.Collections.Generic.List`1[[Item, PowerShell Class Assembly, Version=1.0.0.2, Culture=neutral, PublicKeyToken=null]]" to type "Item".

Environment data

Name                      Value
----                      -----
PSVersion                 7.6.5
PSEdition                 Core
GitCommitId               7.6.5
OS                        Microsoft Windows 10.0.26200
Platform                  Win32NT
PSCompatibleVersions      {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion 2.4
SerializationVersion      1.1.0.1
WSManStackVersion         3.0

Visuals

No response

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs-TriageThe issue is new and needs to be triaged by a work group.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions