System.ComponentModel.Composition: CompositionLock.LockStateForRead/LockStateForWrite enter the lock before boxing its holder, so an OutOfMemoryException leaves the lock held

未关闭
#137,138 1 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
50/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃
技术栈
csharp
领域
backend

调研方向

Start with CompositionLock.cs and the ReadLock and WriteLock constructors in Lock.Reader.cs and Lock.Writer.cs; trace how LockStateForRead and LockStateForWrite acquire and return their holders. Check existing tests for System.ComponentModel.Composition lock behavior, then verify that a failed holder allocation cannot leave the state lock held and that provider disposal still releases shared parts. The issue suggests allocating a reference holder before entering the lock or avoiding boxing.

由索引模型根据 Issue 内容生成。

描述

area-System.Threading untriaged
Description

CompositionLock.LockStateForRead() and CompositionLock.LockStateForWrite() return IDisposable. Each constructs the ReadLock / WriteLock struct, whose constructor enters the underlying ReaderWriterLockSlim (created with LockRecursionPolicy.NoRecursion), and only then boxes the struct to return it (CompositionLock.cs, Lock.Reader.cs, Lock.Writer.cs). The IL of the shipped 10.0.11 assembly:

LockStateForRead:  ldarg.0 | ldfld _isThreadSafe | brfalse.s | ldarg.0 | ldfld _stateLock | newobj ReadLock::.ctor | box Microsoft.Internal.ReadLock | ret | ldsfld _EmptyLockHolder | ret
LockStateForWrite: ldarg.0 | ldfld _isThreadSafe | brfalse.s | ldarg.0 | ldfld _stateLock | newobj WriteLock::.ctor | box Microsoft.Internal.WriteLock | ret | ldsfld _EmptyLockHolder | ret

If the box allocation throws OutOfMemoryException, the lock has been entered, but the caller's using never receives a holder, so nothing releases it. The thread then holds the state lock permanently:

  • CatalogExportProvider.Dispose, ComposablePartExportProvider.Dispose and ImportEngine.Dispose take the state write lock first. On that thread they throw LockRecursionException; on any other thread they would wait indefinitely. Either way the provider never disposes its shared parts.
  • Later state reads on that thread throw (NoRecursion), and writers on other threads wait.

We hit this in a memory-constrained container. A composition ran out of memory, and the later Dispose of the export provider threw LockRecursionException: Write lock may not be acquired with read lock held. That exception replaced the OutOfMemoryException for the caller. The trace of the original out-of-memory error was not kept. The ordering above is the only path we found that leaves a state lock held after the exception unwinds: a plain exception thrown inside composition leaves nothing held.

Reproduction Steps

An OutOfMemoryException cannot be aimed at the box allocation, so the repro takes the read lock through the same method and drops the holder. That leaves the same state as an out-of-memory error on the box.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="System.ComponentModel.Composition" Version="10.0.11" />
  </ItemGroup>
</Project>
using System.ComponentModel.Composition;
using System.ComponentModel.Composition.Hosting;
using System.Reflection;

// A thread-safe catalog export provider with one shared, disposable part, activated once.
var provider = new CatalogExportProvider(new TypeCatalog(typeof(Part)), isThreadSafe: true);
var container = new CompositionContainer(provider);
provider.SourceProvider = container;
_ = container.GetExportedValue<Part>();

// What an OutOfMemoryException on LockStateForRead()'s box leaves behind: the read lock held by this
// thread, with no holder to release it.
var compositionLock = typeof(CatalogExportProvider)
    .GetField("_lock", BindingFlags.Instance | BindingFlags.NonPublic)!
    .GetValue(provider)!;
compositionLock.GetType().GetMethod("LockStateForRead")!.Invoke(compositionLock, null);

try
{
    provider.Dispose(); // takes the state write lock first
    Console.WriteLine("disposed");
}
catch (Exception e)
{
    Console.WriteLine($"{e.GetType().FullName}: {e.Message}");
}

Console.WriteLine($"Part disposed: {Part.Disposed}");

[Export]
[PartCreationPolicy(CreationPolicy.Shared)]
public sealed class Part : IDisposable
{
    public static bool Disposed;
    public void Dispose() => Disposed = true;
}
Expected behavior

An exception thrown after the lock is entered does not leave it held. Dispose completes and disposes the shared part.

Actual behavior
System.Threading.LockRecursionException: Write lock may not be acquired with read lock held. This pattern is prone to deadlocks. Please ensure that read locks are released before taking a write lock. If an upgrade is necessary, use an upgrade lock in place of the read lock.
Part disposed: False
Regression?

Not as far as we know; the pattern is long-standing.

Known Workarounds

Before disposing, release the current thread's hold on the provider's state lock by reflection (CatalogExportProvider._lock → CompositionLock._stateLock → ReadWriteLock._thisLock), and likewise for the import engine and the container's part export provider.

Configuration
  • .NET 10.0.12; System.ComponentModel.Composition 10.0.11 (10.0.11+e2f47b0110ed922f21a1522da67279133ce28f32).
  • Repro run on macOS (arm64); the original failure was on Linux containers.
Other information

A possible fix is to allocate the holder before entering the lock, for example by making the holders LockStateForRead/LockStateForWrite return reference types that enter the lock after allocation, as CompositionLockHolder already is. Another is to return the struct types themselves, so that callers' using statements do not box.

主要语言
C#
星标
18.4k
派生
5.8k
平均合并
2 天 14 小时
30 天内合并 PR
585

环境准备

在 Codespaces 中打开

在浏览器里用你自己的 GitHub 账号启动这个项目的开发容器。

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

dotnet/runtime 的其他 Issue

查看 dotnet/runtime 的全部 Issue

相似的 Issue

更多 C# Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。