Skip to content

AssemblyLoadContext.Unload() doesn't release PE32+ assembly file lock (x64), but PE32/AnyCPU does #131917

Description

@Vercidium

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:
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();
  1. 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.

  1. 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
  1. Skip step 2 (no prior ALC load) — signing succeeds on the first attempt, every time.
  2. 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-MpComputerStatusRealTimeProtectionEnabled: 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-AssemblyLoaderneeds-author-actionAn issue or pull request that requires more info or actions from the author.

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions