fix(core): 修复上下文销毁解绑与并发一致性

- 修复 GameContext 的别名字典与当前活动上下文同步边界,避免解绑与读取路径出现状态漂移
- 修复 Architecture.Destroy() 缺少全局解绑的问题,并补充相关生命周期 XML 文档
- 更新回归测试、CQRS 注册断言与 single-context-priority 跟踪记录
This commit is contained in:
gewuyou 2026-05-07 10:43:07 +08:00
parent ff04a4fbad
commit ee8b6a4deb
8 changed files with 166 additions and 42 deletions

View File

@ -237,6 +237,26 @@ public class ArchitectureLifecycleBehaviorTests
Assert.Throws<InvalidOperationException>(() => probe.GetContext());
}
/// <summary>
/// 验证同步兼容销毁入口同样会解除全局 GameContext 绑定。
/// </summary>
[Test]
public async Task Destroy_Should_Unbind_Context_From_GameContext()
{
var architecture = new PhaseTrackingArchitecture();
await architecture.InitializeAsync();
Assert.That(GameContext.GetByType(architecture.GetType()), Is.SameAs(architecture.Context));
#pragma warning disable CS0618
architecture.Destroy();
#pragma warning restore CS0618
Assert.Throws<InvalidOperationException>(() => GameContext.GetByType(architecture.GetType()));
Assert.Throws<InvalidOperationException>(() => GameContext.GetFirstArchitectureContext());
}
/// <summary>
/// 验证启用 AllowLateRegistration 时,生命周期层会立即初始化后注册的组件,而不是继续沿用初始化期的拒绝策略。
/// 由于公共架构 API 在 Ready 之后会先触发容器限制,此回归测试直接覆盖生命周期协作者的对齐逻辑。

View File

@ -494,6 +494,11 @@ public class MicrosoftDiContainerTests
{
Assert.DoesNotThrow(() =>
_container.RegisterCqrsHandlersFromAssemblies([typeof(DeterministicOrderNotification).Assembly]));
Assert.That(
_container.GetServicesUnsafe.Any(static descriptor =>
descriptor.ServiceType == typeof(INotificationHandler<DeterministicOrderNotification>)),
Is.True);
}
/// <summary>

View File

@ -359,6 +359,11 @@ public abstract class Architecture : IArchitecture
/// <summary>
/// 异步销毁架构及所有组件
/// </summary>
/// <remarks>
/// 无论 <c>_lifecycle.DestroyAsync()</c> 是否抛出异常,该方法都会在 <see langword="finally" /> 中调用
/// <see cref="GameContext.Unbind" />(<see cref="object.GetType" />()),移除当前架构类型在全局上下文表中的绑定。
/// 这样可以阻止新的惰性上下文回退命中已销毁实例;但已经缓存上下文的对象不会被自动重置。
/// </remarks>
public virtual async ValueTask DestroyAsync()
{
try
@ -376,10 +381,22 @@ public abstract class Architecture : IArchitecture
/// <summary>
/// 销毁架构并清理所有组件资源(同步方法,保留用于向后兼容)
/// </summary>
/// <remarks>
/// 该同步兼容入口会与 <see cref="DestroyAsync" /> 保持相同的全局解绑语义;即使销毁过程抛出异常,
/// 也会在 <see langword="finally" /> 中调用 <see cref="GameContext.Unbind" />(<see cref="object.GetType" />())。
/// </remarks>
[Obsolete("建议使用 DestroyAsync() 以支持异步清理")]
public virtual void Destroy()
{
_lifecycle.Destroy();
try
{
_lifecycle.Destroy();
}
finally
{
// 同步销毁同样需要解除全局回退入口,避免兼容调用路径保留过期上下文。
GameContext.Unbind(GetType());
}
}
#endregion

View File

@ -12,6 +12,12 @@ namespace GFramework.Core.Architectures;
/// </summary>
public static class GameContext
{
// ConcurrentDictionary 负责向外暴露安全的实时视图;该锁负责维护“别名字典 + 当前活动上下文”之间的组合不变式。
#if NET9_0_OR_GREATER
private static readonly Lock SyncRoot = new();
#else
private static readonly object SyncRoot = new();
#endif
private static readonly ConcurrentDictionary<Type, IArchitectureContext> ArchitectureDictionary
= new();
private static IArchitectureContext? _currentArchitectureContext;
@ -39,15 +45,19 @@ public static class GameContext
ArgumentNullException.ThrowIfNull(architectureType);
ArgumentNullException.ThrowIfNull(context);
var currentContext = Interlocked.CompareExchange(ref _currentArchitectureContext, context, comparand: null);
if (currentContext != null && !ReferenceEquals(currentContext, context))
throw new InvalidOperationException(
$"GameContext already tracks active context '{currentContext.GetType().Name}'. " +
$"Cannot bind a different context '{context.GetType().Name}'.");
lock (SyncRoot)
{
if (_currentArchitectureContext != null && !ReferenceEquals(_currentArchitectureContext, context))
throw new InvalidOperationException(
$"GameContext already tracks active context '{_currentArchitectureContext.GetType().Name}'. " +
$"Cannot bind a different context '{context.GetType().Name}'.");
if (!ArchitectureDictionary.TryAdd(architectureType, context))
throw new InvalidOperationException(
$"Architecture context for '{architectureType.Name}' already exists");
if (!ArchitectureDictionary.TryAdd(architectureType, context))
throw new InvalidOperationException(
$"Architecture context for '{architectureType.Name}' already exists");
_currentArchitectureContext ??= context;
}
}
/// <summary>
@ -58,8 +68,11 @@ public static class GameContext
/// <exception cref="InvalidOperationException">当当前没有活动上下文时抛出。</exception>
public static IArchitectureContext GetFirstArchitectureContext()
{
if (_currentArchitectureContext is { } context)
return context;
lock (SyncRoot)
{
if (_currentArchitectureContext is { } context)
return context;
}
throw new InvalidOperationException("No active architecture context is currently bound.");
}
@ -76,11 +89,14 @@ public static class GameContext
{
ArgumentNullException.ThrowIfNull(type);
if (ArchitectureDictionary.TryGetValue(type, out var context))
return context;
lock (SyncRoot)
{
if (ArchitectureDictionary.TryGetValue(type, out var context))
return context;
if (_currentArchitectureContext != null && type.IsInstanceOfType(_currentArchitectureContext))
return _currentArchitectureContext;
if (_currentArchitectureContext != null && type.IsInstanceOfType(_currentArchitectureContext))
return _currentArchitectureContext;
}
throw new InvalidOperationException(
$"Architecture context for '{type.Name}' not found");
@ -88,25 +104,30 @@ public static class GameContext
/// <summary>
/// 获取指定类型的架构上下文实例
/// 获取指定类型的架构上下文实例。
/// 该方法会优先复用当前活动上下文,再回退到显式注册的类型别名。
/// </summary>
/// <typeparam name="T">架构上下文类型必须实现IArchitectureContext接口</typeparam>
/// <returns>指定类型的架构上下文实例</returns>
/// <exception cref="InvalidOperationException">当指定类型的架构上下文不存在时抛出</exception>
public static T Get<T>() where T : class, IArchitectureContext
{
if (_currentArchitectureContext is T currentContext)
return currentContext;
lock (SyncRoot)
{
if (_currentArchitectureContext is T currentContext)
return currentContext;
if (ArchitectureDictionary.TryGetValue(typeof(T), out var ctx))
return (T)ctx;
if (ArchitectureDictionary.TryGetValue(typeof(T), out var ctx))
return (T)ctx;
}
throw new InvalidOperationException(
$"Architecture context '{typeof(T).Name}' not found");
}
/// <summary>
/// 尝试获取指定类型的架构上下文实例
/// 尝试获取指定类型的架构上下文实例。
/// 该方法会优先检查当前活动上下文是否兼容目标类型,再回退到显式注册的类型别名。
/// </summary>
/// <typeparam name="T">架构上下文类型必须实现IArchitectureContext接口</typeparam>
/// <param name="context">输出参数如果找到则返回对应的架构上下文实例否则返回null</param>
@ -114,16 +135,19 @@ public static class GameContext
public static bool TryGet<T>(out T? context)
where T : class, IArchitectureContext
{
if (_currentArchitectureContext is T currentContext)
lock (SyncRoot)
{
context = currentContext;
return true;
}
if (_currentArchitectureContext is T currentContext)
{
context = currentContext;
return true;
}
if (ArchitectureDictionary.TryGetValue(typeof(T), out var ctx))
{
context = (T)ctx;
return true;
if (ArchitectureDictionary.TryGetValue(typeof(T), out var ctx))
{
context = (T)ctx;
return true;
}
}
context = null;
@ -131,30 +155,54 @@ public static class GameContext
}
/// <summary>
/// 移除指定类型的架构上下文绑定
/// 移除指定类型的架构上下文绑定。
/// 当最后一个指向当前活动上下文的别名被移除时,也会同步清空当前活动上下文指针。
/// </summary>
/// <param name="architectureType">要移除的架构类型</param>
/// <exception cref="ArgumentNullException"><paramref name="architectureType" /> 为 <see langword="null" />。</exception>
public static void Unbind(Type architectureType)
{
ArgumentNullException.ThrowIfNull(architectureType);
if (!ArchitectureDictionary.TryRemove(architectureType, out var removedContext))
return;
lock (SyncRoot)
{
if (!ArchitectureDictionary.TryRemove(architectureType, out var removedContext))
return;
if (_currentArchitectureContext == null || !ReferenceEquals(_currentArchitectureContext, removedContext))
return;
if (_currentArchitectureContext == null || !ReferenceEquals(_currentArchitectureContext, removedContext))
return;
if (!ArchitectureDictionary.Values.Any(current => ReferenceEquals(current, removedContext)))
Interlocked.CompareExchange(ref _currentArchitectureContext, null, removedContext);
if (!HasAliasForContext(removedContext))
_currentArchitectureContext = null;
}
}
/// <summary>
/// 清空所有架构上下文绑定
/// 清空所有架构上下文绑定,并重置当前活动上下文。
/// </summary>
public static void Clear()
{
ArchitectureDictionary.Clear();
Interlocked.Exchange(ref _currentArchitectureContext, null);
lock (SyncRoot)
{
ArchitectureDictionary.Clear();
_currentArchitectureContext = null;
}
}
/// <summary>
/// 判断当前是否仍存在指向同一上下文实例的其他类型别名。
/// </summary>
/// <param name="context">被移除绑定原本指向的上下文实例。</param>
/// <returns>如果还有其他别名指向同一实例则返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
private static bool HasAliasForContext(IArchitectureContext context)
{
foreach (var current in ArchitectureDictionary.Values)
{
if (ReferenceEquals(current, context))
return true;
}
return false;
}
}

View File

@ -15,6 +15,7 @@ public sealed class GameContextProvider : IArchitectureContextProvider
/// 获取当前的架构上下文。
/// </summary>
/// <returns>架构上下文实例</returns>
/// <exception cref="InvalidOperationException">当前没有已绑定的活动架构上下文时抛出。</exception>
public IArchitectureContext GetContext()
{
return GameContext.GetFirstArchitectureContext();

View File

@ -48,6 +48,8 @@ public abstract class ContextAwareBase : IContextAware
/// <remarks>
/// 当 <see cref="Context" /> 为空时,该实现会直接回退到 <see cref="GameContext.GetFirstArchitectureContext" /> 返回的当前活动上下文。
/// 该回退过程不执行额外同步,也不支持替换 provider如需这些能力请改用生成的 ContextAware 实现。
/// 一旦回退结果被写入 <see cref="Context" />,后续即使关联架构解除 <see cref="GameContext" /> 绑定,
/// 该实例仍会保留原引用,调用方需要自行约束其生命周期或改用支持 provider 协调的生成实现。
/// </remarks>
IArchitectureContext IContextAware.GetContext()
{

View File

@ -10,13 +10,14 @@
## 当前恢复点
- 恢复点编号:`SINGLE-CONTEXT-PRIORITY-RP-001`
- 当前阶段:`Phase 2`
- 恢复点编号:`SINGLE-CONTEXT-PRIORITY-RP-003`
- 当前阶段:`Phase 3`
- 当前结论:
- `GameContext` 已从“字典首个枚举值即默认上下文”收敛为“单活动上下文 + 类型别名兼容查找”;同一全局上下文表不再允许并存两个不同上下文实例
- `MicrosoftDiContainer` 的预冻结 `Get<T>()` / `Get(Type)` 已改为复用实例可见性收集逻辑,和 `GetAll*` 的实例暴露规则保持一致
- `IIocContainer` XML 文档已明确预冻结查询与 `Contains<T>()` 的契约边界,避免把注册阶段查询误读为完整 DI 激活语义
- `Architecture.DestroyAsync()` 现会在生命周期销毁完成后显式解除 `GameContext` 绑定,防止已销毁架构继续充当默认上下文回退入口
- 当前 PR review 跟进中,`Architecture.Destroy()` 已补齐相同解绑语义,`GameContext` 也改为通过统一临界区维护别名字典与当前活动上下文的一致性
- 当前分支从 `main` 创建,已完成 `git pull --ff-only origin main`
## 当前活跃事实
@ -35,6 +36,7 @@
- `Contains<T>()` 在预冻结阶段目前更接近“是否存在注册”,不等同于“是否能立即解析实例”;本轮若不改其行为,需要在文档和测试中明确这一点
- `ResolveCqrsRegistrationService()` 仍要求注册阶段对 `ICqrsRegistrationService` 可见的是实例绑定;若后续改成工厂或实现类型注册,需要额外设计注册阶段激活 helper
- 现有解绑逻辑通过 `Architecture.GetType()` 移除初始化期绑定;若后续引入更多显式上下文别名,需同步评估是否要在销毁时额外移除这些别名
- `GameContext` 当前通过粗粒度锁保护组合状态一致性;若该入口后续进入高频并发热点,需要基于真实性能数据再评估更细粒度方案
## 最近权威验证
@ -52,8 +54,15 @@
- 结果:通过,`32/32` passed
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
- 结果:再次通过,`0 warning / 0 error`
- `python3 scripts/license-header.py --check`
- 结果:再次通过,所有受支持文件头合法
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~GameContextTests|FullyQualifiedName~ContextProviderTests|FullyQualifiedName~ContextAwareTests|FullyQualifiedName~ArchitectureLifecycleBehaviorTests|FullyQualifiedName~MicrosoftDiContainerTests"`
- 结果:通过,`87/87` passed
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
- 结果:再次通过,`0 warning / 0 error`
## 下一推荐步骤
1. 若后续继续推进,可评估是否要把 `GameContext.ArchitectureReadOnlyDictionary` 标记为兼容层,并收口其公开使用面
2. 若 CQRS runtime seam 计划改成工厂式注册,再单独补“注册阶段激活 helper”的设计与测试
2. 若 `GameContext` 的组合锁成为瓶颈,再基于压测结果讨论更细粒度的原子状态模型
3. 若 CQRS runtime seam 计划改成工厂式注册,再单独补“注册阶段激活 helper”的设计与测试

View File

@ -42,3 +42,25 @@
### 当前下一步
1. 若后续要进一步彻底移除全局回退,可单独评估 `GameContext` 公开别名字典的收口策略与生成器默认 provider 的进一步简化空间
### 阶段PR review 跟进修复SINGLE-CONTEXT-PRIORITY-RP-003
- Review 来源:
- PR `#332``refactor/single-context-priority`)上的 CodeRabbit unresolved outside-diff / nitpick comments
- PR `#332` 上 Greptile 的两个 latest-head open threads
- 本轮确认并接受的结论:
- `Architecture.Destroy()``DestroyAsync()` 存在语义漂移,前者缺少 `GameContext.Unbind(GetType())` 清理,仍然会泄漏过期全局回退入口
- `GameContext` 仅靠 `ConcurrentDictionary` + `_currentArchitectureContext` 的分离更新无法保证组合状态一致;`Bind` / `Unbind` / `Clear` 与读取路径需要统一同步边界
- `GameContextProvider.GetContext()``ContextAwareBase.GetContext()``Architecture.DestroyAsync()` 等处的 XML 文档仍未完全反映最新生命周期语义
- `RegisterCqrsHandlersFromAssemblies_Should_Resolve_Registration_Service_When_Registered_As_Instance` 需要从“仅不抛异常”加强为“确实注册了 handler”
- 本轮实现摘要:
- 为 `GameContext` 新增统一临界区,保证别名字典与当前活动上下文指针的状态切换一致
- 为 `Architecture.Destroy()` 增加 `finally` 解绑,和异步销毁路径对齐
- 补充 `GameContextProvider``ContextAwareBase``Architecture``GameContext` 的 XML 文档说明
- 新增同步 `Destroy()` 解绑回归测试,并加强 CQRS 注册断言
- 验证与环境备注:
- `python3 scripts/license-header.py --check` 通过
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~GameContextTests|FullyQualifiedName~ContextProviderTests|FullyQualifiedName~ContextAwareTests|FullyQualifiedName~ArchitectureLifecycleBehaviorTests|FullyQualifiedName~MicrosoftDiContainerTests"` 通过,`87/87` passed
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release` 通过,`0 warning / 0 error`
- 中途尝试把 `dotnet test``dotnet build` 并行执行时再次触发 `GFramework.Core.pdb` 占用重试;按仓库规则改为串行直跑后结果稳定通过
- `System.Threading.Lock``net8.0` 目标下不可用,因此最终采用 `#if NET9_0_OR_GREATER` 条件锁声明,同时保留 `net8.0``object` 锁兼容实现