fix(core): 修复 PR review 回归问题

- 修复 CoroutineScheduler 在零初始容量下的扩容边界并补充回归测试
- 修复 Store dispatch 快照阶段的异常回滚逻辑并补充异常安全测试
- 更新 analyzer-warning-reduction 的 tracking 与 trace 以记录 RP-010 验证结果
This commit is contained in:
GeWuYou 2026-04-21 12:56:28 +08:00
parent c61ee140a1
commit aa78dfbf51
6 changed files with 143 additions and 12 deletions

View File

@ -345,6 +345,20 @@ public class CoroutineSchedulerTests
Assert.That(_scheduler.ActiveCoroutineCount, Is.EqualTo(10));
}
/// <summary>
/// 验证调度器在零初始容量下会在首次启动协程时自动扩容,而不是写入越界。
/// </summary>
[Test]
public void Run_Should_Grow_From_Zero_Initial_Capacity()
{
var scheduler = new CoroutineScheduler(new TestTimeSource(), initialCapacity: 0);
var handle = scheduler.Run(CreateYieldingCoroutine(new WaitOneFrame()));
Assert.That(handle.IsValid, Is.True);
Assert.That(scheduler.ActiveCoroutineCount, Is.EqualTo(1));
}
/// <summary>
/// 验证协程调度器应该使用提供的时间源
/// </summary>
@ -563,4 +577,4 @@ public class TestTimeSource : ITimeSource
DeltaTime = 0.1;
CurrentTime += DeltaTime;
}
}
}

View File

@ -1,3 +1,6 @@
using System.Collections;
using System.Reflection;
namespace GFramework.Core.Tests.StateManagement;
/// <summary>
@ -384,6 +387,32 @@ public class StoreTests
Assert.That(logs, Is.EqualTo(new[] { "dynamic:before", "dynamic:after" }));
}
/// <summary>
/// 测试当 dispatch 作用域在快照阶段抛出异常时Store 不会残留“正在分发”标记而锁死后续调用。
/// </summary>
[Test]
public void Dispatch_Should_Reset_Dispatching_Flag_When_Snapshot_Creation_Throws()
{
var store = new Store<CounterState>(
new CounterState(0, "Player"),
actionMatchingMode: StoreActionMatchingMode.IncludeAssignableTypes);
store.RegisterReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount });
var reducers = GetReducersDictionary(store);
var throwingActionType = new ThrowingAssignableType(typeof(IncrementAction), "simulated reducer snapshot failure");
reducers.Add(throwingActionType, CreateEmptyReducerRegistrationList(reducers));
Assert.That(
() => store.Dispatch(new IncrementAction(1)),
Throws.InvalidOperationException.With.Message.EqualTo("simulated reducer snapshot failure"));
reducers.Remove(throwingActionType);
Assert.That(() => store.Dispatch(new IncrementAction(1)), Throws.Nothing);
Assert.That(store.State.Count, Is.EqualTo(1));
}
/// <summary>
/// 测试未命中的 action 仍会记录诊断信息,但不会改变状态。
/// </summary>
@ -681,6 +710,32 @@ public class StoreTests
return store;
}
/// <summary>
/// 读取 Store 内部 reducer 字典,以便在异常安全回归测试中注入受控的异常源。
/// </summary>
/// <param name="store">要读取的 Store 实例。</param>
/// <returns>Store 当前持有的 reducer 字典引用。</returns>
private static IDictionary GetReducersDictionary(Store<CounterState> store)
{
var reducersField = typeof(Store<CounterState>).GetField("_reducers", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Unable to locate Store reducer dictionary field.");
return (IDictionary)(reducersField.GetValue(store)
?? throw new InvalidOperationException("Store reducer dictionary should not be null."));
}
/// <summary>
/// 创建与 Store 私有 reducer 注册列表兼容的空列表实例。
/// </summary>
/// <param name="reducers">现有 reducer 字典,用于推断私有列表元素类型。</param>
/// <returns>可写入私有 reducer 字典的空列表。</returns>
private static object CreateEmptyReducerRegistrationList(IDictionary reducers)
{
var valueType = reducers.GetType().GenericTypeArguments[1];
return Activator.CreateInstance(valueType)
?? throw new InvalidOperationException("Unable to create an empty reducer registration list.");
}
/// <summary>
/// 用于测试的计数器状态。
/// 使用 record 保持逻辑不可变语义,便于 Store 基于状态快照进行比较和断言。
@ -876,4 +931,20 @@ public class StoreTests
next();
}
}
}
/// <summary>
/// 用于在回归测试中稳定模拟 <see cref="Type.IsAssignableFrom(Type)"/> 失败的代理类型。
/// </summary>
private sealed class ThrowingAssignableType(Type delegatingType, string message) : TypeDelegator(delegatingType)
{
/// <summary>
/// 在 Store 创建 reducer 快照时抛出受控异常,验证 dispatch 作用域能够正确回滚。
/// </summary>
/// <param name="typeInfo">待比较的 action 运行时类型。</param>
/// <returns>此实现不会正常返回。</returns>
public override bool IsAssignableFrom(Type? typeInfo)
{
throw new InvalidOperationException(message);
}
}
}

View File

@ -16,7 +16,7 @@ namespace GFramework.Core.Coroutine;
/// </remarks>
/// <param name="timeSource">缩放时间源,提供调度器默认推进所使用的时间数据。</param>
/// <param name="instanceId">协程实例编号,用于生成带宿主前缀的句柄。</param>
/// <param name="initialCapacity">调度器初始槽位容量。</param>
/// <param name="initialCapacity">调度器初始槽位容量;允许为 0此时首次启动协程会按需自动扩容。</param>
/// <param name="enableStatistics">是否启用协程统计功能。</param>
/// <param name="realtimeTimeSource">
/// 非缩放时间源。
@ -1036,7 +1036,9 @@ public sealed class CoroutineScheduler(
/// </summary>
private void Expand()
{
Array.Resize(ref _slots, _slots.Length * 2);
// 允许构造器以 0 容量启动,用于极简场景或测试;首次分配时至少扩到 1避免后续写槽位越界。
var expandedLength = Math.Max(1, _slots.Length * 2);
Array.Resize(ref _slots, expandedLength);
}
/// <summary>

View File

@ -828,11 +828,20 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
EnsureNotDispatching();
_isDispatching = true;
var context = new StoreDispatchContext<TState>(action!, _state);
stateComparerSnapshot = _stateComparer;
middlewaresSnapshot = CreateMiddlewareSnapshotCore();
reducersSnapshot = CreateReducerSnapshotCore(context.ActionType);
return context;
try
{
var context = new StoreDispatchContext<TState>(action!, _state);
stateComparerSnapshot = _stateComparer;
middlewaresSnapshot = CreateMiddlewareSnapshotCore();
reducersSnapshot = CreateReducerSnapshotCore(context.ActionType);
return context;
}
catch
{
// 进入 dispatch 标记早于快照构建;如果这里抛异常,必须同步回滚标记,避免后续调用被永久判定为嵌套分发。
_isDispatching = false;
throw;
}
}
}

View File

@ -7,10 +7,10 @@
## 当前恢复点
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-009`
- 当前阶段:`Phase 9`
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-010`
- 当前阶段:`Phase 10`
- 当前焦点:
- 当前 `MA0048` 已在 `GFramework.Core``net8.0` warnings-only 基线中清零;下一轮切到 `MA0046`
- 当前 PR #265 的 follow-up 已收口;下一轮恢复到 `MA0046` 主批次
- 后续继续按 warning 类型和数量批处理,而不是回退到按单文件切片推进
- 当某一轮主类型数量不足时,允许顺手合并其他低冲突 warning 类型,`MA0015``MA0077`
只是当前最明显的低数量示例,不构成限定
@ -21,6 +21,7 @@
- 已完成 `GFramework.Core``GFramework.Cqrs``GFramework.Godot` 与部分 source generator 的低风险 warning 清理
- 已完成多轮 CodeRabbit follow-up 修复,并用定向测试与项目/解决方案构建验证了关键回归风险
- 已完成当前 PR #265 review follow-up修复 `CoroutineScheduler` 的零容量扩容边界,并补上 `Store` dispatch 作用域的异常安全回滚
- 当前 `PauseStackManager``Store``CoroutineScheduler``GFramework.Core``MA0048`
文件/类型命名冲突已从 active 入口移除;主题内剩余 warning 主要集中在 `MA0046` delegate 形状、
`MA0016` 集合抽象接口、`MA0002` comparer 重载,以及 `MA0015` / `MA0077` 两个低数量尾项
@ -41,6 +42,9 @@
不同模型的 subagent 并行处理
- `RP-009` 在不改公共 API 的前提下,将同名泛型家族收拢到与类型名一致的单文件中,清空当前 `GFramework.Core`
`net8.0` 基线中的 `MA0048`,并通过定向 build/test 验证 `Command``Query``Event` 路径未回归
- `RP-010` 使用 `gframework-pr-review` 复核当前分支 PR #265 后,修复了仍在本地成立的两个 follow-up 风险:
`CoroutineScheduler``initialCapacity: 0` 扩容越界,以及 `Store` 在 dispatch 快照阶段抛异常时可能残留
`_isDispatching = true` 的锁死问题
- 当前工作树分支 `fix/analyzer-warning-reduction-batch` 已在 `ai-plan/public/README.md` 建立 topic 映射
## 当前风险
@ -94,6 +98,11 @@
- 结果:`15 Warning(s)``0 Error(s)`;当前 `GFramework.Core` `net8.0` warnings-only 输出中已不再出现 `MA0048`
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~AbstractAsyncCommandTests|FullyQualifiedName~QueryExecutorTests|FullyQualifiedName~AbstractAsyncQueryTests|FullyQualifiedName~EventTests" -m:1 -p:RestoreFallbackFolders="" -nologo`
- 结果:`83 Passed``0 Failed`
- `RP-010` 的定向验证结果:
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release --no-restore -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -nologo`
- 结果:`15 Warning(s)``0 Error(s)`;新增修复未引入新的 `GFramework.Core` `net8.0` 构建错误
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~CoroutineSchedulerTests.Run_Should_Grow_From_Zero_Initial_Capacity|FullyQualifiedName~StoreTests.Dispatch_Should_Reset_Dispatching_Flag_When_Snapshot_Creation_Throws" -m:1 -p:RestoreFallbackFolders="" -nologo`
- 结果:`2 Passed``0 Failed`
- active 跟踪文件只保留当前恢复点、活跃事实、风险与下一步,不再重复保存已完成阶段的长篇历史
## 下一步

View File

@ -1,5 +1,31 @@
# Analyzer Warning Reduction 追踪
## 2026-04-21 — RP-010
### 阶段PR #265 follow-up 收口RP-010
- 使用 `gframework-pr-review` 抓取当前分支 PR #265 的 latest head review threads、CodeRabbit review body、MegaLinter 摘要与 CTRF
测试结果;确认最新 unresolved thread 只剩 `CoroutineScheduler` 零容量扩容边界
- 本地复核后确认两处仍成立的风险:
- `CoroutineScheduler.Expand()``_slots.Length == 0` 时会把容量从 `0` 扩到 `0`,首次 `Run` 写槽位会越界
- `Store.EnterDispatchScope()``_isDispatching = true` 之后、快照构建完成之前若抛异常,会留下永久的嵌套分发误判
- 实施最小修复:
- 将 `Expand()` 调整为 `Math.Max(1, _slots.Length * 2)`,保持已有倍增策略,只补上零容量边界
- 为 `EnterDispatchScope()` 增加快照阶段的异常回滚,确保 `_isDispatching` 与实际 dispatch 生命周期保持一致
- 新增回归测试覆盖零容量启动路径,以及 dispatch 快照阶段抛错后的可恢复性
- 当前 PR 信号复核结论:
- CTRF最新评论显示 `2135 passed / 0 failed`
- MegaLinter唯一告警仍是 CI 中 `dotnet-format` restore 失败,未发现新的本地代码格式问题
- 旧 review body 中提到的 `Store` 异常安全问题虽未表现为最新 open thread但在本地代码中仍可成立因此一并收口
- 定向验证命令:
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release --no-restore -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -nologo`
- 结果:`15 Warning(s)``0 Error(s)`
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~CoroutineSchedulerTests.Run_Should_Grow_From_Zero_Initial_Capacity|FullyQualifiedName~StoreTests.Dispatch_Should_Reset_Dispatching_Flag_When_Snapshot_Creation_Throws" -m:1 -p:RestoreFallbackFolders="" -nologo`
- 结果:`2 Passed``0 Failed`
- 下一步建议:
- 若继续本主题,恢复到 `MA0046` 主批次,不再停留在当前 PR follow-up
- 若 PR review 还出现新线程,继续遵守“只修复当前本地仍成立的问题”的策略
## 2026-04-21 — RP-009
### 阶段:`MA0048` 批次收口RP-009