From e0b80905734180fcf6057a3e4938986dbef3f9c6 Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Thu, 18 Jun 2026 09:02:32 +0100 Subject: [PATCH 1/2] Unload target assemblies before publishing the migrations bundle Fixes #25555 `dotnet ef migrations bundle` loads the target and startup assemblies into the tool process to discover the DbContext, then shells out to `dotnet publish`, which rebuilds and overwrites those same bin\...\*.dll files. On Windows a loaded assembly file is locked, so the copy fails with MSB3027/MSB3021 ("being used by another process"). The assemblies were loaded into the non-collectible default AssemblyLoadContext, so they could never be released while the tool process was alive. Load them into a collectible AssemblyLoadContext instead and unload it when the executor is disposed (before publish runs), releasing the files. --- src/ef/ReflectionOperationExecutor.cs | 52 +++++++++++-- .../ReflectionOperationExecutorTest.cs | 77 +++++++++++++++++++ test/ef.Tests/ef.Tests.csproj | 3 +- 3 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 test/ef.Tests/ReflectionOperationExecutorTest.cs diff --git a/src/ef/ReflectionOperationExecutor.cs b/src/ef/ReflectionOperationExecutor.cs index 9314a4326dd..2155b237ab7 100644 --- a/src/ef/ReflectionOperationExecutor.cs +++ b/src/ef/ReflectionOperationExecutor.cs @@ -16,11 +16,11 @@ namespace Microsoft.EntityFrameworkCore.Tools; internal class ReflectionOperationExecutor : OperationExecutorBase { - private readonly object _executor; - private readonly Assembly _commandsAssembly; + private object _executor; + private Assembly _commandsAssembly; private const string ReportHandlerTypeName = "Microsoft.EntityFrameworkCore.Design.OperationReportHandler"; private const string ResultHandlerTypeName = "Microsoft.EntityFrameworkCore.Design.OperationResultHandler"; - private readonly Type _resultHandlerType; + private Type _resultHandlerType; private string? _efcoreVersion; #if NET private AssemblyLoadContext? _assemblyLoadContext; @@ -56,7 +56,9 @@ public ReflectionOperationExecutor( AppDomain.CurrentDomain.SetData("DataDirectory", dataDirectory); } +#if !NET AppDomain.CurrentDomain.AssemblyResolve += ResolveAssembly; +#endif #if NET _commandsAssembly = DesignAssemblyPath != null @@ -105,14 +107,20 @@ protected AssemblyLoadContext AssemblyLoadContext return _assemblyLoadContext; } - AssemblyLoadContext.Default.Resolving += (context, name) => + // Load the target and startup assemblies into a collectible context so they can be + // unloaded when this executor is disposed. The tool loads these assemblies to discover + // the DbContext, then shells out to 'dotnet publish', which rebuilds and overwrites the + // same bin\...\*.dll files. On Windows a loaded assembly file is locked, so the copy + // fails unless the file has been released first. See https://github.com/dotnet/efcore/issues/25555. + var assemblyLoadContext = new AssemblyLoadContext("EntityFrameworkCore.Tools", isCollectible: true); + assemblyLoadContext.Resolving += (context, name) => { var assemblyPath = Path.Combine(AppBasePath, name.Name + ".dll"); return File.Exists(assemblyPath) ? context.LoadFromAssemblyPath(assemblyPath) : null; }; - _assemblyLoadContext = AssemblyLoadContext.Default; + _assemblyLoadContext = assemblyLoadContext; - return AssemblyLoadContext.Default; + return assemblyLoadContext; } } #endif @@ -152,6 +160,7 @@ protected override void Execute(string operationName, object resultHandler, IDic resultHandler, arguments); +#if !NET private Assembly? ResolveAssembly(object? sender, ResolveEventArgs args) { var assemblyName = new AssemblyName(args.Name); @@ -173,7 +182,36 @@ protected override void Execute(string operationName, object resultHandler, IDic return null; } +#endif public override void Dispose() - => AppDomain.CurrentDomain.AssemblyResolve -= ResolveAssembly; + { +#if NET + // Drop every reference into the collectible context and unload it so the target and startup + // assemblies are released before 'dotnet publish' tries to overwrite their bin\...\*.dll + // files. See https://github.com/dotnet/efcore/issues/25555. + _executor = null!; + _resultHandlerType = null!; + _commandsAssembly = null!; + + if (_assemblyLoadContext is { IsCollectible: true } assemblyLoadContext) + { + _assemblyLoadContext = null; + + // The unload only completes once the GC observes that nothing references the context; + // force it here so the file lock is released by the time the caller starts publishing. + var weakReference = new WeakReference(assemblyLoadContext); + assemblyLoadContext.Unload(); + assemblyLoadContext = null; + + for (var i = 0; weakReference.IsAlive && i < 10; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + } +#else + AppDomain.CurrentDomain.AssemblyResolve -= ResolveAssembly; +#endif + } } diff --git a/test/ef.Tests/ReflectionOperationExecutorTest.cs b/test/ef.Tests/ReflectionOperationExecutorTest.cs new file mode 100644 index 00000000000..5d2b841b1cd --- /dev/null +++ b/test/ef.Tests/ReflectionOperationExecutorTest.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +extern alias eftool; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; + +namespace Microsoft.EntityFrameworkCore.Tools; + +public class ReflectionOperationExecutorTest +{ + // Regression test for https://github.com/dotnet/efcore/issues/25555. + // The bundle command loads the user's assemblies into the tool process to discover the + // DbContext, then shells out `dotnet publish` which rebuilds and overwrites those same + // bin\...\*.dll files. On Windows a loaded assembly file is locked, so the copy fails. + // The executor must therefore release the user assemblies (unload its load context) when + // disposed, before publish runs. We can't observe the Windows lock cross-platform, but we + // can observe the portable substrate: after disposal the target assembly is no longer + // loaded in the process. + [Fact] + public void Dispose_unloads_the_target_assembly() + { + var targetDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(targetDir); + try + { + var build = new BuildSource { TargetDir = targetDir, Sources = { ["Nothing.cs"] = "public class Nothing { }" } }; + var targetPath = build.Build().TargetPath; + var designPath = Assembly.Load(new AssemblyName(OperationExecutorBase.DesignAssemblyName)).Location; + + RunOperation(targetPath, designPath); + + for (var i = 0; i < 10 && IsLoaded(targetPath); i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + Assert.False(IsLoaded(targetPath), "Target assembly was still loaded after the executor was disposed."); + } + finally + { + Directory.Delete(targetDir, recursive: true); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void RunOperation(string targetPath, string designPath) + { + using var executor = new ReflectionOperationExecutor( + assembly: targetPath, + startupAssembly: targetPath, + designAssembly: designPath, + project: null, + projectDir: null, + dataDirectory: null, + rootNamespace: null, + language: null, + nullable: false, + remainingArguments: [], + reportHandler: new eftool::Microsoft.EntityFrameworkCore.Design.OperationReportHandler()); + + // Mirrors what the bundle command does: an operation that loads the target assembly and + // marshals its result back across the load-context boundary (the dynamic result handler). + _ = executor.GetContextTypes().ToList(); + + Assert.True(IsLoaded(targetPath), "Target assembly was not loaded by the operation; test would be vacuous."); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static bool IsLoaded(string targetPath) + => AssemblyLoadContext.All + .SelectMany(c => c.Assemblies) + .Any(a => !a.IsDynamic + && !string.IsNullOrEmpty(a.Location) + && string.Equals(a.Location, targetPath, StringComparison.OrdinalIgnoreCase)); +} diff --git a/test/ef.Tests/ef.Tests.csproj b/test/ef.Tests/ef.Tests.csproj index c8df176e8ed..a1b4ccbf355 100644 --- a/test/ef.Tests/ef.Tests.csproj +++ b/test/ef.Tests/ef.Tests.csproj @@ -4,6 +4,7 @@ $(DefaultNetCoreTargetFramework) Microsoft.EntityFrameworkCore.Tools true + true @@ -35,7 +36,7 @@ - + From 2f9f549c1f6214f8d62db539eb98c8c520e8e35e Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Thu, 18 Jun 2026 10:24:53 +0100 Subject: [PATCH 2/2] Make unload test cleanup resilient to lingering file handle on Windows The recursive temp-dir delete in the test's finally ran immediately after the load context was unloaded. On Windows the OS can release the assembly's file handle slightly after the context is collected, so the delete intermittently threw UnauthorizedAccessException and masked the (passing) assertion. Unix allows deleting a file with an open handle, so this only surfaced on Windows CI. Retry the cleanup briefly and give up quietly; it is a test-cleanup concern only (in the real tool, publish runs much later). --- .../ReflectionOperationExecutorTest.cs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/test/ef.Tests/ReflectionOperationExecutorTest.cs b/test/ef.Tests/ReflectionOperationExecutorTest.cs index 5d2b841b1cd..71335231c14 100644 --- a/test/ef.Tests/ReflectionOperationExecutorTest.cs +++ b/test/ef.Tests/ReflectionOperationExecutorTest.cs @@ -40,7 +40,29 @@ public void Dispose_unloads_the_target_assembly() } finally { - Directory.Delete(targetDir, recursive: true); + DeleteDirectory(targetDir); + } + } + + private static void DeleteDirectory(string path) + { + // On Windows the just-unloaded assembly's file handle can be released slightly after the + // load context is collected, so a recursive delete may briefly fail with a sharing/access + // violation. This is a test-cleanup concern only (in the real tool, publish runs much + // later), so retry for a moment and then give up quietly rather than fail the test. + for (var i = 0; i < 20; i++) + { + try + { + Directory.Delete(path, recursive: true); + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + Thread.Sleep(50); + } } }