refactor(pause): 收口 PauseStackManager 长方法告警

- 重构 PauseStackManager 的销毁与 Pop 流程,拆分锁内状态迁移与锁外通知阶段
- 新增 PauseStackManager 销毁恢复通知回归测试,覆盖多暂停组销毁补发行为
- 更新 analyzer warning reduction 主题的 active tracking 与 trace,记录 RP-005 验证结果和下一恢复点
This commit is contained in:
GeWuYou 2026-04-21 09:18:20 +08:00
parent 358b1e9cca
commit ff1996e81b
4 changed files with 299 additions and 144 deletions

View File

@ -394,6 +394,35 @@ public class PauseStackManagerTests
Assert.That(_manager.IsPaused(PauseGroup.Audio), Is.False);
}
/// <summary>
/// 验证销毁时会向所有仍暂停的组补发恢复通知。
/// </summary>
[Test]
public async Task DestroyAsync_Should_NotifyResumedGroups()
{
var resumedGroups = new List<PauseGroup>();
var mockHandler = new MockPauseHandler();
_manager.RegisterHandler(mockHandler);
_manager.OnPauseStateChanged += (_, e) =>
{
if (!e.IsPaused)
{
resumedGroups.Add(e.Group);
}
};
_manager.Push("Global", PauseGroup.Global);
_manager.Push("Gameplay", PauseGroup.Gameplay);
mockHandler.Reset();
await _manager.DestroyAsync();
Assert.That(mockHandler.CallCount, Is.EqualTo(2));
Assert.That(mockHandler.LastIsPaused, Is.False);
Assert.That(resumedGroups, Is.EquivalentTo(new[] { PauseGroup.Global, PauseGroup.Gameplay }));
}
/// <summary>
/// 验证并发Push是线程安全的
/// </summary>
@ -469,4 +498,4 @@ public class PauseStackManagerTests
LastIsPaused = null;
}
}
}
}

View File

@ -26,69 +26,17 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
public ValueTask DestroyAsync()
{
if (_disposed)
{
return ValueTask.CompletedTask;
List<PauseGroup> pausedGroups;
IPauseHandler[] handlersSnapshot;
_lock.EnterWriteLock();
try
{
if (_disposed)
return ValueTask.CompletedTask;
_disposed = true;
// 收集所有当前暂停的组
pausedGroups = _pauseStacks
.Where(kvp => kvp.Value.Count > 0)
.Select(kvp => kvp.Key)
.ToList();
// 获取处理器快照
handlersSnapshot = _handlers.ToArray();
// 清理所有数据结构
_pauseStacks.Clear();
_tokenMap.Clear();
_handlers.Clear();
_logger.Debug("PauseStackManager destroyed");
}
finally
{
_lock.ExitWriteLock();
}
// 在锁外通知所有之前暂停的组恢复,保持生命周期信号一致
foreach (var group in pausedGroups)
var destroySnapshot = TryBeginDestroy();
if (destroySnapshot == null)
{
_logger.Debug($"Notifying handlers of destruction: Group={group}, IsPaused=false");
foreach (var handler in handlersSnapshot.OrderBy(h => h.Priority))
{
try
{
handler.OnPauseStateChanged(group, false);
}
catch (Exception ex)
{
_logger.Error($"Handler {handler.GetType().Name} failed during destruction", ex);
}
}
// 触发事件
try
{
RaisePauseStateChanged(group, false);
}
catch (Exception ex)
{
_logger.Error($"Event subscriber failed during destruction for group {group}", ex);
}
return ValueTask.CompletedTask;
}
// 释放锁资源
NotifyDestroyedGroups(destroySnapshot.Value);
_lock.Dispose();
return ValueTask.CompletedTask;
@ -163,74 +111,17 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
public bool Pop(PauseToken token)
{
if (!token.IsValid)
{
return false;
bool found;
bool shouldNotify = false;
PauseGroup notifyGroup = PauseGroup.Global;
_lock.EnterWriteLock();
try
{
ThrowIfDisposed();
if (!_tokenMap.TryGetValue(token.Id, out var entry))
{
_logger.Warn($"Attempted to pop invalid/expired token: {token.Id}");
return false;
}
var group = entry.Group;
var stack = _pauseStacks[group];
var wasPaused = stack.Count > 0;
// 从栈中移除
var tempStack = new Stack<PauseEntry>();
found = false;
while (stack.Count > 0)
{
var current = stack.Pop();
if (current.TokenId == token.Id)
{
found = true;
break;
}
tempStack.Push(current);
}
// 恢复栈结构
while (tempStack.Count > 0)
{
stack.Push(tempStack.Pop());
}
if (found)
{
_tokenMap.Remove(token.Id);
_logger.Debug($"Pause popped: {entry.Reason} (Group: {group}, Remaining: {stack.Count})");
// 状态变化检测:从暂停 → 未暂停
if (wasPaused && stack.Count == 0)
{
shouldNotify = true;
notifyGroup = group;
}
}
}
finally
{
_lock.ExitWriteLock();
}
// 在锁外通知处理器,避免死锁
if (shouldNotify)
var result = TryPopEntry(token);
if (result.ShouldNotify)
{
NotifyHandlers(notifyGroup, false);
NotifyHandlers(result.NotifyGroup, false);
}
return found;
return result.Found;
}
/// <summary>
@ -443,6 +334,200 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
}
}
/// <summary>
/// 采集销毁所需的快照并清空内部状态。
/// </summary>
/// <returns>
/// 成功进入销毁阶段时返回销毁快照;如果其他线程已先完成销毁,则返回 <see langword="null" />。
/// </returns>
/// <remarks>
/// 该方法只负责锁内状态迁移,把外部回调与事件派发留到锁外执行,
/// 以避免在生命周期结束阶段持锁调用用户代码。
/// </remarks>
private DestroySnapshot? TryBeginDestroy()
{
_lock.EnterWriteLock();
try
{
if (_disposed)
{
return null;
}
_disposed = true;
var pausedGroups = CollectPausedGroups();
var handlersSnapshot = CreateHandlerSnapshot();
_pauseStacks.Clear();
_tokenMap.Clear();
_handlers.Clear();
_logger.Debug("PauseStackManager destroyed");
return new DestroySnapshot(pausedGroups, handlersSnapshot);
}
finally
{
_lock.ExitWriteLock();
}
}
/// <summary>
/// 在销毁后向所有先前处于暂停状态的分组补发恢复通知。
/// </summary>
/// <param name="destroySnapshot">销毁阶段采集的分组与处理器快照。</param>
private void NotifyDestroyedGroups(DestroySnapshot destroySnapshot)
{
foreach (var group in destroySnapshot.PausedGroups)
{
_logger.Debug($"Notifying handlers of destruction: Group={group}, IsPaused=false");
NotifyHandlersSnapshot(group, false, destroySnapshot.HandlersSnapshot, isDestroying: true);
RaiseDestroyStateChanged(group);
}
}
/// <summary>
/// 在锁内执行令牌移除,并返回锁外通知所需的信息。
/// </summary>
/// <param name="token">要移除的暂停令牌。</param>
/// <returns>包含本次弹出结果和后续通知决策的快照。</returns>
/// <remarks>
/// Pop 支持移除非栈顶令牌,因此这里会先临时转移栈元素,再恢复原有顺序,
/// 只在最后一个暂停请求被移除时触发恢复通知。
/// </remarks>
private PopResult TryPopEntry(PauseToken token)
{
_lock.EnterWriteLock();
try
{
ThrowIfDisposed();
if (!_tokenMap.TryGetValue(token.Id, out var entry))
{
_logger.Warn($"Attempted to pop invalid/expired token: {token.Id}");
return PopResult.NotFound;
}
var stack = _pauseStacks[entry.Group];
var wasPaused = stack.Count > 0;
var found = RemoveEntryFromStack(stack, token.Id);
if (!found)
{
return PopResult.NotFound;
}
_tokenMap.Remove(token.Id);
_logger.Debug($"Pause popped: {entry.Reason} (Group: {entry.Group}, Remaining: {stack.Count})");
return new PopResult(true, wasPaused && stack.Count == 0, entry.Group);
}
finally
{
_lock.ExitWriteLock();
}
}
/// <summary>
/// 从指定暂停栈中移除目标令牌,并保持其他暂停请求的原始顺序。
/// </summary>
/// <param name="stack">要修改的暂停栈。</param>
/// <param name="tokenId">目标令牌标识。</param>
/// <returns>如果找到了目标令牌则返回 <see langword="true" />。</returns>
private static bool RemoveEntryFromStack(Stack<PauseEntry> stack, Guid tokenId)
{
var tempStack = new Stack<PauseEntry>();
var found = false;
while (stack.Count > 0)
{
var current = stack.Pop();
if (current.TokenId == tokenId)
{
found = true;
break;
}
tempStack.Push(current);
}
while (tempStack.Count > 0)
{
stack.Push(tempStack.Pop());
}
return found;
}
/// <summary>
/// 收集当前仍处于暂停状态的分组列表。
/// </summary>
/// <returns>包含所有暂停中的分组的数组。</returns>
private PauseGroup[] CollectPausedGroups()
{
return _pauseStacks
.Where(kvp => kvp.Value.Count > 0)
.Select(kvp => kvp.Key)
.ToArray();
}
/// <summary>
/// 按优先级创建处理器快照,确保锁外通知仍保持确定性顺序。
/// </summary>
/// <returns>已按优先级排序的处理器快照。</returns>
private IPauseHandler[] CreateHandlerSnapshot()
{
return _handlers
.OrderBy(handler => handler.Priority)
.ToArray();
}
/// <summary>
/// 统一使用给定的处理器快照派发暂停状态变化通知。
/// </summary>
/// <param name="group">发生状态变化的暂停组。</param>
/// <param name="isPaused">新的暂停状态。</param>
/// <param name="handlersSnapshot">要通知的处理器快照。</param>
/// <param name="isDestroying">是否处于销毁补发路径。</param>
private void NotifyHandlersSnapshot(
PauseGroup group,
bool isPaused,
IReadOnlyList<IPauseHandler> handlersSnapshot,
bool isDestroying)
{
foreach (var handler in handlersSnapshot)
{
try
{
handler.OnPauseStateChanged(group, isPaused);
}
catch (Exception ex)
{
var message = isDestroying
? $"Handler {handler.GetType().Name} failed during destruction"
: $"Handler {handler.GetType().Name} failed";
_logger.Error(message, ex);
}
}
}
/// <summary>
/// 在销毁路径中独立保护事件通知,避免订阅方异常中断其他分组的恢复信号。
/// </summary>
/// <param name="group">需要补发恢复事件的暂停组。</param>
private void RaiseDestroyStateChanged(PauseGroup group)
{
try
{
RaisePauseStateChanged(group, false);
}
catch (Exception ex)
{
_logger.Error($"Event subscriber failed during destruction for group {group}", ex);
}
}
/// <summary>
/// 内部查询暂停状态的方法,不加锁。
/// </summary>
@ -467,7 +552,7 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
_lock.EnterReadLock();
try
{
handlersSnapshot = _handlers.OrderBy(h => h.Priority).ToArray();
handlersSnapshot = CreateHandlerSnapshot();
}
finally
{
@ -475,17 +560,7 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
}
// 在锁外遍历快照并通知处理器
foreach (var handler in handlersSnapshot)
{
try
{
handler.OnPauseStateChanged(group, isPaused);
}
catch (Exception ex)
{
_logger.Error($"Handler {handler.GetType().Name} failed", ex);
}
}
NotifyHandlersSnapshot(group, isPaused, handlersSnapshot, isDestroying: false);
// 触发事件
RaisePauseStateChanged(group, isPaused);
@ -508,4 +583,25 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
protected override void OnInit()
{
}
}
/// <summary>
/// 锁内采集的销毁快照,供锁外补发恢复通知使用。
/// </summary>
/// <param name="PausedGroups">销毁前仍处于暂停状态的分组。</param>
/// <param name="HandlersSnapshot">按优先级排序后的处理器快照。</param>
private readonly record struct DestroySnapshot(PauseGroup[] PausedGroups, IPauseHandler[] HandlersSnapshot);
/// <summary>
/// Pop 操作的锁内结果快照。
/// </summary>
/// <param name="Found">是否成功移除了目标令牌。</param>
/// <param name="ShouldNotify">是否需要在锁外发出恢复通知。</param>
/// <param name="NotifyGroup">需要通知的暂停组。</param>
private readonly record struct PopResult(bool Found, bool ShouldNotify, PauseGroup NotifyGroup)
{
/// <summary>
/// 表示未找到目标令牌时的默认结果。
/// </summary>
public static PopResult NotFound { get; } = new(false, false, PauseGroup.Global);
}
}

View File

@ -7,21 +7,22 @@
## 当前恢复点
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-004`
- 当前阶段:`Phase 4`
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-005`
- 当前阶段:`Phase 5`
- 当前焦点:
- 已完成当前分支 PR #263 的最新 review follow-up本地确认并修复 `GFramework.Cqrs/Internal/CqrsHandlerRegistrar.cs`
`null!` 可空契约问题,同时消除 active trace 的重复标题 `MD024`
- 已确认 PR 上的测试信号为 `2134 Passed / 0 Failed`MegaLinter 唯一告警来自 CI 中 `dotnet-format` restore 失败,
当前本地 follow-up 不需要额外处理
- 下一轮若继续推进,优先从 `PauseStackManager``CoroutineScheduler``Store` 的剩余 `MA0051` 中只选一个切入点
- 已完成 `GFramework.Core/Pause/PauseStackManager.cs``MA0051` 收口:将 `DestroyAsync``Pop` 拆分为锁内状态迁移、
栈调整和锁外通知三个阶段,同时保持日志、事件与销毁补发语义不变
- 已为销毁路径补充 `PauseStackManagerTests.DestroyAsync_Should_NotifyResumedGroups` 回归测试,覆盖“销毁时向所有仍暂停组补发恢复通知”
- 下一轮若继续推进,优先在 `CoroutineScheduler``Store` 的剩余 `MA0051` 中只选一个切入点,不回到已完成的
`PauseStackManager`
## 当前状态摘要
- 已完成 `GFramework.Core``GFramework.Cqrs``GFramework.Godot` 与部分 source generator 的低风险 warning 清理
- 已完成多轮 CodeRabbit follow-up 修复,并用定向测试与项目/解决方案构建验证了关键回归风险
- 当前 `GFramework.Cqrs` 的剩余 warning 热点已从 active 入口移除;主题内剩余 warning 主要集中在 `GFramework.Core` 长方法、
文件/类型命名冲突、delegate 形状和少量公共集合抽象接口问题
- 当前 `PauseStackManager` 的长方法 warning 已从 active 入口移除;主题内剩余 warning 主要集中在
`GFramework.Core/Coroutine/CoroutineScheduler.cs``GFramework.Core/StateManagement/Store.cs`、文件/类型命名冲突、
delegate 形状和少量公共集合抽象接口问题
## 当前活跃事实
@ -30,6 +31,7 @@
- `RP-002` 已在不改公共契约的前提下完成 `CqrsHandlerRegistrar` 结构拆分,并通过定向 build/test 验证
- `RP-003` 已在不改生命周期契约的前提下完成 `ArchitectureLifecycle` 初始化主流程拆分,并通过定向 build/test 验证
- `RP-004` 已完成当前 PR review follow-up修复 `TryCreateGeneratedRegistry` 的可空 `out` 契约并清理 trace 文档重复标题
- `RP-005` 已在不改公共 API 的前提下完成 `PauseStackManager` 两个 `MA0051` 的结构拆分,并补充销毁通知回归测试
- 当前工作树分支 `fix/analyzer-warning-reduction-batch` 已在 `ai-plan/public/README.md` 建立 topic 映射
## 当前风险
@ -58,11 +60,16 @@
- `RP-004` 的定向验证结果:
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release --no-restore -p:TargetFramework=net8.0 -p:UseSharedCompilation=false -p:RestoreFallbackFolders=`
- 结果:`0 Warning(s)``0 Error(s)`
- `RP-005` 的定向验证结果:
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:TargetFramework=net8.0 -p:RestoreFallbackFolders= -nologo -clp:"Summary;WarningsOnly"`
- 结果:`27 Warning(s)``0 Error(s)``PauseStackManager.cs` 已不再出现在 `MA0051` 列表中
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter FullyQualifiedName~PauseStackManagerTests -p:RestoreFallbackFolders=`
- 结果:`25 Passed``0 Failed`
- active 跟踪文件只保留当前恢复点、活跃事实、风险与下一步,不再重复保存已完成阶段的长篇历史
## 下一步
1. 若要继续该主题,先读 active tracking再按需展开历史归档中的 warning 热点与验证记录
2. 优先在 `GFramework.Core/Pause/PauseStackManager.cs`、`GFramework.Core/Coroutine/CoroutineScheduler.cs`
`GFramework.Core/StateManagement/Store.cs` `MA0051` 中只选一个继续,不要在同一轮同时扩多个风险面
2. 优先在 `GFramework.Core/Coroutine/CoroutineScheduler.cs` 与 `GFramework.Core/StateManagement/Store.cs`
`MA0051` 中只选一个继续,不要在同一轮同时扩多个风险面
3. 若本主题确认暂缓,可保持当前归档状态,不需要再恢复 `local-plan/`

View File

@ -1,5 +1,28 @@
# Analyzer Warning Reduction 追踪
## 2026-04-21 — RP-005
### 阶段PauseStackManager `MA0051` 收口RP-005
- 按 active tracking 中“继续只选一个 `GFramework.Core` 结构性切入点”的约束,本轮选择
`GFramework.Core/Pause/PauseStackManager.cs`,因为该文件体量明显小于 `CoroutineScheduler``Store`
且已有稳定的 `PauseStackManagerTests` 覆盖暂停栈、跨组独立性、事件通知与并发 `Push/Pop` 行为
- 先用 `warnings-only` 定向构建确认 `DestroyAsync``Pop` 仍分别命中 `MA0051`,再把逻辑拆分为:
- `TryBeginDestroy`
- `NotifyDestroyedGroups`
- `TryPopEntry`
- `RemoveEntryFromStack`
- 额外抽出 `CreateHandlerSnapshot``NotifyHandlersSnapshot`,统一普通通知与销毁补发路径的处理器排序和异常日志,
保持原有“锁内采集快照、锁外调用处理器与事件”的并发策略不变
- 为销毁路径新增 `DestroyAsync_Should_NotifyResumedGroups`,验证当多个暂停组在销毁前仍为暂停态时,
处理器和事件订阅者都会收到 `IsPaused=false` 的恢复信号
- 验证通过:
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:TargetFramework=net8.0 -p:RestoreFallbackFolders= -nologo -clp:"Summary;WarningsOnly"`
- 结果:`27 Warning(s)``0 Error(s)``PauseStackManager.cs` 已不再出现在 `MA0051` 列表
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter FullyQualifiedName~PauseStackManagerTests -p:RestoreFallbackFolders=`
- 结果:`25 Passed``0 Failed`
- 下一步保持原节奏:只在 `CoroutineScheduler``Store` 中二选一继续,不与其他 warning 家族混做
## 2026-04-21 — RP-003
### 阶段Architecture 生命周期 `MA0051` 收口RP-003