Repository navigation
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
- Write a descriptive title.
- Make sure you are able to repro it on the latest released version
- Search the existing issues.
- Refer to the FAQ.
- Refer to Differences between Windows PowerShell 5.1 and PowerShell.
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 $dirRun it a second time with -Count 0 (no flood) for comparison.
What we found (mechanism)
Two process-wide caches get out of step:
CompiledScriptBlock.s_cachedScriptsholds compiled scripts keyed by (path, content). When it
holds more than 1024 entries, it is cleared completely. While the entry forClass.ps1is
cached,Import-Module -Forcere-runs the same compiled script, and[Item]keeps its type.
After the clear, the re-import compilesClass.ps1again, and a second
PowerShell Class Assembly(Version 1.0.0.2) defines a newItem.System.Management.Automation.Language.TypeCache.s_cachemaps (type name,
TypeResolutionState) to a resolved type. Nothing clears it. It still mapsItemand
System.Collections.Generic.List[Item]to the 1.0.0.1 types.Functions.ps1is compiled again
after the re-import, and its[List[Item]]resolves through this cache toList<Item v1>.
[Item]@{...}resolves to the newItem v2. The two do not fit.
Evidence:
- After the re-import, the
-Listparameter type ofAdd-Itemhas the OLDItemas its generic
argument. With-Count 0, it has the onlyItem. - If the TypeCache entries whose name contains
Itemare removed (by reflection) before the
re-import, the repro passes: the parameter type then uses the newItem. - If the function is in the
.psm1itself 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 -Forcenormally 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 sameImport-Module -Forceworks 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 differentItem
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 -ForceinBeforeAll) 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.0Visuals
No response
Activity