AssemblyLoadContext.Unload() doesn't release PE32+ assembly file lock (x64), but PE32/AnyCPU does
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
### 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
1. Build a .NET class library targeting `net8.0` with `PlatformTarget=x64` (PE32+ image, `CorFlags` NOT marked `ILONLY|32BITPREFERRED`).
2. In a console app, load the DLL into a collectible `AssemblyLoadContext`, enumerate `assembly.DefinedTypes` (or any reflection over the loaded types), then unload:
```csharp
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();
```
3. Immediately after, attempt to open the same file for write access:
```csharp
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.
4. 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: \myapp.dll
```
5. Skip step 2 (no prior ALC load) — signing succeeds on the first attempt, every time.
6. 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: \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:
- [dotnet/runtime#13370](https://github.com/dotnet/runtime/issues/13370) — closest match, unresolved, predates .NET 8.
- [dotnet/runtime#39609](https://github.com/dotnet/runtime/issues/39609) — regression in .NET 5 Preview 6, closed via #39974, but clearly not fully fixed given this repro on .NET 8.
- [dotnet/runtime#66091](https://github.com/dotnet/runtime/issues/66091) — same theme for PDB files specifically, targeted at 8.0 milestone.
**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.
Contributor guide
Research direction
Start by reproducing the issue with the provided net8.0 x64 PE32+ AssemblyLoadContext.LoadFromAssemblyPath and Unload sequence, then compare it with PE32 and plain PE32+ assemblies. Investigate the AssemblyLoadContext.Unload entry point and use the mentioned handle.exe or ETW/WinDbg approaches to identify the retained resource. Done means isolating the necessary conditions and demonstrating that the affected file can be reopened for write access after unloading.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- operating-systems
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100