Description
Loading a PE32+ (x64) assembly into a collectible AssemblyLoadContext, then calling Unload() + GC.Collect() + GC.WaitForPendingFinalizers(), does not reliably release the OS-level file mapping backing that assembly. A subsequent attempt to open the file for write access — in-process, or via a child signtool.exe sign — fails with a sharing violation, even though no other process holds a handle on the file (confirmed with an elevated Sysinternals handle.exe scan at the moment of failure) and the file isn't locked microseconds before or after.
The equivalent PE32 (AnyCPU / 32-bit-preferred) build of the identical source assembly never exhibits this. The only reliable fix found is running the assembly-load check in a separate short-lived process, which frees all locks/handles when it exits.
Reproduction Steps
- Build a .NET class library targeting
net8.0 with PlatformTarget=x64 (PE32+ image, CorFlags NOT marked ILONLY|32BITPREFERRED).
- In a console app, load the DLL into a collectible
AssemblyLoadContext, enumerate assembly.DefinedTypes (or any reflection over the loaded types), then unload:
var ctx = new AssemblyLoadContext(name: null, isCollectible: true);
var assembly = ctx.LoadFromAssemblyPath(dllPath);
var types = assembly.DefinedTypes.ToList(); // force enumeration
ctx.Unload();
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
- Immediately after, attempt to open the same file for write access:
using var probe = File.Open(dllPath, FileMode.Open, FileAccess.ReadWrite, FileShare.Read);
This throws an IOException ("being used by another process"). Retrying in a loop with GC.Collect() and short sleeps doesn't help — 10 attempts over 2+ seconds all failed.
- Alternatively, invoke
signtool.exe sign ... dllPath as a child process instead of a direct File.Open. Fails identically:
SignTool Error: The file is being used by another process.
SignTool Error: An error occurred while attempting to sign: <path>\myapp.dll
- Skip step 2 (no prior ALC load) — signing succeeds on the first attempt, every time.
- Use a PE32 (AnyCPU) build of the identical source instead of PE32+ (x64) — signing succeeds on the first attempt, every time, even after the ALC load/unload in step 2.
Expected behavior
After Unload() + GC.Collect() + GC.WaitForPendingFinalizers() returns, the assembly's OS-level file mapping should be released, allowing the file to be reopened for write access (by this or another process) shortly after — matching the behavior already seen with PE32 (AnyCPU) builds.
Actual behavior
The file remains locked for write access indefinitely (observed for 2+ seconds across 10 retries, in isolation with nothing else running), specifically for PE32+ (x64) builds. Both a direct File.Open(..., FileAccess.ReadWrite, ...) probe and a child signtool.exe sign process fail with a sharing violation:
SignTool Error: The file is being used by another process.
SignTool Error: An error occurred while attempting to sign: <path>\myapp.dll
No other process holds a handle on the file at the time of failure (confirmed via elevated handle.exe), and a read-only probe (FileAccess.Read) succeeds throughout — only write-access opens fail.
Regression?
Unknown. Not yet tested against .NET 9/10 or earlier .NET Core versions. Related (but not identical) issues have been reported against older .NET Core/5/6 releases — see "Other information" below.
Known Workarounds
Run the AssemblyLoadContext-based inspection in a separate, short-lived child process (e.g., re-invoke the same executable with a hidden switch that does just the load + reflection + prints results to stdout, then exits). Process exit unconditionally releases all OS-level resources, so the parent can safely sign the file immediately after the child exits.
Configuration
- .NET SDK 8 (assembly under test targets
net8.0)
- Windows 10 Version 10.0.19045 Build 19045
- Architecture: x64. The bug is specific to PE32+ (x64) assemblies — the equivalent PE32 (AnyCPU) build of the same source never reproduces it.
- Repro assembly is a normal C# class library post-processed by a third-party .NET obfuscator that intentionally injects invalid/malformed metadata as an anti-decompilation measure — unconfirmed whether this is a necessary ingredient (see "Other information").
- signtool.exe from a recent Windows 10 SDK, signing with a hardware-backed (smart card / USB token) certificate over
/tr (RFC 3161 timestamping).
- Not Blazor-related.
Other information
What was ruled out:
- Build server / MSBuild node reuse: killed all
MSBuild.exe/VBCSCompiler.exe processes before reproducing, and also reproduced with --disable-build-servers on every dotnet invocation — still fails.
- IDE background builds: reproduced running purely from a terminal with no IDE open — still fails.
- Antivirus / EDR on-access scanning: real-time protection confirmed disabled (
Get-MpComputerStatus → RealTimeProtectionEnabled: False), no AV process running, elevated fltmc filters/handle scan showed nothing relevant.
- Stale process from a previous run: a full reboot doesn't change the behavior; reproduces on the first run after reboot.
- Batch signing: reproduces identically when signing the affected file alone, in its own
signtool process, as long as the ALC load happened earlier in the calling process.
- Read-only vs write-access sharing semantics: a probe requesting only
FileAccess.Read reports the file as openable while signtool (which needs write access) still fails — read-only checks give a false negative. Requesting FileAccess.ReadWrite fails consistently, matching signtool's real behavior.
Suspected mechanism: AssemblyLoadContext.Unload() is documented as asynchronous, relying on GC to collect the loaded assembly before native resources are released. GC.Collect() + GC.WaitForPendingFinalizers() (even doubled, bracketing the wait) isn't sufficient here within any bounded number of retries, specifically for a PE32+ image. Possibly the CLR's native image loader keeps a CreateFileMapping-backed section object alive for PE32+ assemblies through a path the ALC's normal finalization sequence doesn't reach — or the malformed/injected metadata from the obfuscator interferes with tearing down that state, keeping the mapping alive indefinitely rather than just delaying release.
Related issues — same broad symptom (Unload() + GC not releasing a Windows file lock) has been reported before, but none mention a PE32/PE32+ split or signtool specifically:
Open questions:
- Does this reproduce with a plain PE32+ assembly (no obfuscator / no injected invalid metadata), or is malformed metadata required?
- Does this reproduce on .NET 9/10, or is it specific to .NET 8's
AssemblyLoadContext?
- Would
MetadataLoadContext (metadata-only, should never map the assembly for execution) avoid the issue entirely? Not yet tested — would need reworking the reflection code to use MetadataReader-style APIs instead of live TypeInfo/GetMethods.
- Is the retained resource actually a memory-mapped section tied to PE32+ loading, or something else (e.g. a JIT-related native allocation)? A kernel-level trace (ETW with the .NET runtime provider, or a WinDbg handle dump at the moment of failure) would confirm what's still alive.
Description
Loading a PE32+ (x64) assembly into a collectible
AssemblyLoadContext, then callingUnload()+GC.Collect()+GC.WaitForPendingFinalizers(), does not reliably release the OS-level file mapping backing that assembly. A subsequent attempt to open the file for write access — in-process, or via a childsigntool.exe sign— fails with a sharing violation, even though no other process holds a handle on the file (confirmed with an elevated Sysinternalshandle.exescan at the moment of failure) and the file isn't locked microseconds before or after.The equivalent PE32 (AnyCPU / 32-bit-preferred) build of the identical source assembly never exhibits this. The only reliable fix found is running the assembly-load check in a separate short-lived process, which frees all locks/handles when it exits.
Reproduction Steps
net8.0withPlatformTarget=x64(PE32+ image,CorFlagsNOT markedILONLY|32BITPREFERRED).AssemblyLoadContext, enumerateassembly.DefinedTypes(or any reflection over the loaded types), then unload:This throws an
IOException("being used by another process"). Retrying in a loop withGC.Collect()and short sleeps doesn't help — 10 attempts over 2+ seconds all failed.signtool.exe sign ... dllPathas a child process instead of a directFile.Open. Fails identically:Expected behavior
After
Unload()+GC.Collect()+GC.WaitForPendingFinalizers()returns, the assembly's OS-level file mapping should be released, allowing the file to be reopened for write access (by this or another process) shortly after — matching the behavior already seen with PE32 (AnyCPU) builds.Actual behavior
The file remains locked for write access indefinitely (observed for 2+ seconds across 10 retries, in isolation with nothing else running), specifically for PE32+ (x64) builds. Both a direct
File.Open(..., FileAccess.ReadWrite, ...)probe and a childsigntool.exe signprocess fail with a sharing violation:No other process holds a handle on the file at the time of failure (confirmed via elevated
handle.exe), and a read-only probe (FileAccess.Read) succeeds throughout — only write-access opens fail.Regression?
Unknown. Not yet tested against .NET 9/10 or earlier .NET Core versions. Related (but not identical) issues have been reported against older .NET Core/5/6 releases — see "Other information" below.
Known Workarounds
Run the
AssemblyLoadContext-based inspection in a separate, short-lived child process (e.g., re-invoke the same executable with a hidden switch that does just the load + reflection + prints results to stdout, then exits). Process exit unconditionally releases all OS-level resources, so the parent can safely sign the file immediately after the child exits.Configuration
net8.0)/tr(RFC 3161 timestamping).Other information
What was ruled out:
MSBuild.exe/VBCSCompiler.exeprocesses before reproducing, and also reproduced with--disable-build-serverson everydotnetinvocation — still fails.Get-MpComputerStatus→RealTimeProtectionEnabled: False), no AV process running, elevatedfltmc filters/handle scan showed nothing relevant.signtoolprocess, as long as the ALC load happened earlier in the calling process.FileAccess.Readreports the file as openable whilesigntool(which needs write access) still fails — read-only checks give a false negative. RequestingFileAccess.ReadWritefails consistently, matching signtool's real behavior.Suspected mechanism:
AssemblyLoadContext.Unload()is documented as asynchronous, relying on GC to collect the loaded assembly before native resources are released.GC.Collect()+GC.WaitForPendingFinalizers()(even doubled, bracketing the wait) isn't sufficient here within any bounded number of retries, specifically for a PE32+ image. Possibly the CLR's native image loader keeps aCreateFileMapping-backed section object alive for PE32+ assemblies through a path the ALC's normal finalization sequence doesn't reach — or the malformed/injected metadata from the obfuscator interferes with tearing down that state, keeping the mapping alive indefinitely rather than just delaying release.Related issues — same broad symptom (
Unload()+ GC not releasing a Windows file lock) has been reported before, but none mention a PE32/PE32+ split or signtool specifically:Open questions:
AssemblyLoadContext?MetadataLoadContext(metadata-only, should never map the assembly for execution) avoid the issue entirely? Not yet tested — would need reworking the reflection code to useMetadataReader-style APIs instead of liveTypeInfo/GetMethods.