fix(core): 修复 AsyncLogAppender 刷新竞态

- 修复 AsyncLogAppender 在队列已被后台线程提前清空时 Flush 仍可能超时失败的问题
- 新增 AsyncLogAppender 已处理队列场景的稳定回归测试并重新验证 GFramework.Core.Tests
- 更新 analyzer-warning-reduction 的 tracking 与 trace 记录 PR267 failed-test follow-up
This commit is contained in:
GeWuYou 2026-04-21 17:32:50 +08:00
parent 685897f2de
commit a9f86348ff
4 changed files with 129 additions and 15 deletions

View File

@ -120,6 +120,30 @@ public class AsyncLogAppenderTests
Assert.That(observedResults, Has.All.True);
}
[Test]
public void Flush_WhenEntriesAlreadyProcessed_Should_Still_ReportSuccess()
{
using var appendCompleted = new ManualResetEventSlim();
var innerAppender = new SignalingAppender(appendCompleted);
using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 10);
var observedResults = new List<bool>();
asyncAppender.Append(new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Already processed", null, null));
Assert.That(appendCompleted.Wait(TimeSpan.FromSeconds(1)), Is.True);
asyncAppender.OnFlushCompleted += (_, eventArgs) => observedResults.Add(eventArgs.Success);
var result = asyncAppender.Flush(TimeSpan.FromSeconds(1));
Assert.Multiple(() =>
{
Assert.That(result, Is.True);
Assert.That(observedResults, Has.Count.EqualTo(1));
Assert.That(observedResults, Has.All.True);
Assert.That(innerAppender.FlushCount, Is.EqualTo(1));
});
}
[Test]
public void Dispose_ShouldProcessRemainingEntries()
{
@ -308,6 +332,32 @@ public class AsyncLogAppenderTests
}
}
private sealed class SignalingAppender : ILogAppender
{
private readonly ManualResetEventSlim _appendCompleted;
public SignalingAppender(ManualResetEventSlim appendCompleted)
{
_appendCompleted = appendCompleted;
}
public int FlushCount { get; private set; }
public void Append(LogEntry entry)
{
_appendCompleted.Set();
}
public void Flush()
{
FlushCount++;
}
public void Dispose()
{
}
}
private class ThrowingAppender : ILogAppender
{
public void Append(LogEntry entry)

View File

@ -23,6 +23,7 @@ public sealed class AsyncLogAppender : ILogAppender
private readonly Action<Exception>? _processingErrorHandler;
private readonly Task _processingTask;
private bool _disposed;
private int _isProcessingEntry;
private volatile bool _flushRequested;
/// <summary>
@ -140,6 +141,7 @@ public sealed class AsyncLogAppender : ILogAppender
// 请求刷新
_flushRequested = true;
TrySignalFlushCompletion();
try
{
@ -166,6 +168,7 @@ public sealed class AsyncLogAppender : ILogAppender
{
try
{
Volatile.Write(ref _isProcessingEntry, 1);
_innerAppender.Append(entry);
}
catch (Exception ex)
@ -173,18 +176,12 @@ public sealed class AsyncLogAppender : ILogAppender
// 后台消费失败只通过显式回调暴露,避免测试宿主将 stderr 误判为测试告警。
ReportProcessingError(ex);
}
// 检查是否有刷新请求且通道已空
if (_flushRequested && _channel.Reader.Count == 0)
finally
{
_innerAppender.Flush();
// 发出完成信号
if (_flushSemaphore.CurrentCount == 0)
{
_flushSemaphore.Release();
}
Volatile.Write(ref _isProcessingEntry, 0);
}
TrySignalFlushCompletion();
}
}
catch (OperationCanceledException)
@ -209,6 +206,29 @@ public sealed class AsyncLogAppender : ILogAppender
}
}
/// <summary>
/// 在后台消费者已经处理完当前条目且队列为空时完成挂起的 Flush 请求。
/// </summary>
private void TrySignalFlushCompletion()
{
if (!_flushRequested)
{
return;
}
if (Volatile.Read(ref _isProcessingEntry) != 0 || _channel.Reader.Count != 0)
{
return;
}
_innerAppender.Flush();
if (_flushSemaphore.CurrentCount == 0)
{
_flushSemaphore.Release();
}
}
/// <summary>
/// 上报后台处理异常,同时隔离观察者自身抛出的错误,避免终止处理循环。
/// 取消相关异常表示关闭流程中的预期控制流,不应被视为后台处理失败。

View File

@ -7,12 +7,12 @@
## 当前恢复点
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-014`
- 当前阶段:`Phase 14`
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-015`
- 当前阶段:`Phase 15`
- 当前焦点:
- 当前分支 PR #267 latest CodeRabbit unresolved threads、outside-diff comment 与 nitpick comment 已完成本地复核
- 本轮优先收口仍然成立的 follow-up`AsyncLogAppender` 接口路径重复触发 `OnFlushCompleted`、事件/XML 契约缺口、
`PhaseChanged` 迁移文档说明与 `ai-plan` 基线表述歧义
- 当前分支 PR #267失败测试已通过 `$gframework-pr-review` 与本地整包测试完成复核
- 已确认并修复 `AsyncLogAppender.Flush()` 在“后台线程先清空队列”场景下可能超时返回 `false` 的竞态
- 已补上稳定回归测试,避免只在整包 `GFramework.Core.Tests` 里偶发暴露的刷新完成信号问题再次回归
- 下一轮默认恢复到 `MA0016``MA0002` 低风险批次;`MA0015``MA0077` 继续作为尾项顺手吸收
- `GFramework.Godot``Timing.cs` 已同步适配新事件签名,但当前 worktree 的 Godot restore 资产仍受 Windows fallback package folder 干扰,独立 build 需在修复资产后补跑
- 后续继续按 warning 类型和数量批处理,而不是回退到按单文件切片推进
@ -32,6 +32,8 @@
并同步更新 `GFramework.Godot` 订阅点、定向测试与 `docs/zh-CN` 示例
- 已完成当前 PR #267 review follow-up修复 `AsyncLogAppender``ILogAppender.Flush()` 双重完成通知,并补齐
`PhaseChanged` / `CoroutineExceptionEventArgs` XML 文档、`PhaseChanged` 迁移说明和 `ai-plan` 基线注释
- 已完成当前 PR #267 failed-test follow-up修复 `AsyncLogAppender.Flush()` 在队列已被后台线程提前清空时仍可能
等待满默认超时并返回 `false` 的竞态,并通过整包 `GFramework.Core.Tests` 重新验证
- 当前 `GFramework.Core` `net8.0` warnings-only 基线已降到 `9` 条;剩余 warning 集中在
`MA0016` 集合抽象接口、`MA0002` comparer 重载,以及 `MA0015` / `MA0077` 两个低数量尾项
@ -62,6 +64,8 @@
非标准签名;`GFramework.Core` `net8.0` warnings-only 基线由 `15` 降至 `9`
- `RP-014` 使用 `gframework-pr-review` 复核当前分支 PR #267 的 latest head review threads、outside-diff comment 与
nitpick comment 后,确认 8 条高信号项中仍成立的是 1 个行为 bug 与 7 个文档/测试/跟踪缺口,并按最小改动收口
- `RP-015` 使用 `$gframework-pr-review` 复核 PR #267 的 CTRF 失败测试评论后,确认 `AsyncLogAppender` 仍存在
“队列已空但 Flush 仍超时失败”的竞态;该问题在本地整包 `GFramework.Core.Tests` 中可复现,现已修复并补上稳定回归测试
- 当前工作树分支 `fix/analyzer-warning-reduction-batch` 已在 `ai-plan/public/README.md` 建立 topic 映射
## 当前风险
@ -149,6 +153,11 @@
- 结果:`9 Warning(s)``0 Error(s)``AsyncLogAppender` 行为修复与 XML / 文档补充未引入新的 `GFramework.Core` `net8.0` 构建错误
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-restore --filter "FullyQualifiedName~CoroutineSchedulerTests.Scheduler_Should_Raise_OnCoroutineException_With_EventArgs|FullyQualifiedName~AsyncLogAppenderTests.Flush_Should_Raise_OnFlushCompleted_With_Sender_And_Result|FullyQualifiedName~AsyncLogAppenderTests.ILogAppender_Flush_Should_Raise_OnFlushCompleted_Only_Once|FullyQualifiedName~ArchitectureLifecycleBehaviorTests.InitializeAsync_Should_Raise_PhaseChanged_With_Sender_And_EventArgs" -m:1 -p:RestoreFallbackFolders="" -nologo`
- 结果:`4 Passed``0 Failed`
- `RP-015` 的验证结果:
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-restore --disable-build-servers --filter "FullyQualifiedName~AsyncLogAppenderTests"`
- 结果:`15 Passed``0 Failed`
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-restore --disable-build-servers`
- 结果:`1607 Passed``0 Failed`
- active 跟踪文件只保留当前恢复点、活跃事实、风险与下一步,不再重复保存已完成阶段的长篇历史
## 下一步

View File

@ -1,5 +1,40 @@
# Analyzer Warning Reduction 追踪
## 2026-04-21 — RP-015
### 阶段PR #267 failed-test follow-up 收口RP-015
- 触发背景:
- 用户指出“测试好像挂了”,按 `$gframework-pr-review` 重新抓取当前分支 PR #267 的 review / checks / CTRF 评论
- PR 评论里同时存在一次 `2143 passed / 0 failed` 与一次 `1 failed` 的 CTRF 报告;失败用例为
`AsyncLogAppenderTests.ILogAppender_Flush_Should_Raise_OnFlushCompleted_Only_Once`
- 复核过程:
- 先跑定向单测时该用例可以单独通过,因此继续核对 PR head commit 与本地整包测试,避免把旧评论误判成当前状态
- 在 `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-restore --disable-build-servers`
下成功复现相同失败,确认问题仍存在于当前代码,而不是单纯的 PR 评论残留
- 同时发现当前沙箱内如果用 shell 循环反复启动 `dotnet test`,会触发 `MSBuild` named pipe `Permission denied`
的环境噪音;后续验证改为单次命令并显式加 `--disable-build-servers`
- 根因结论:
- `AsyncLogAppender.Flush()` 只依赖后台消费循环在处理完某个条目后检查 `_flushRequested`
- 当调用方执行 `Flush()` 前,后台线程已经把最后一个条目消费完并离开检查点时,`Flush()` 会一直等到默认超时,
最终通过 `OnFlushCompleted` 发出一次 `Success=false` 的错误完成通知
- 实施修复:
- 为 `AsyncLogAppender` 增加“当前是否仍有条目在途处理”的状态跟踪
- 抽出 `TrySignalFlushCompletion()`,让 `Flush()` 在请求发出后先做一次即时完成判定;后台循环在每次处理结束后也复用
这条判定路径
- 在 `AsyncLogAppenderTests` 中新增 `Flush_WhenEntriesAlreadyProcessed_Should_Still_ReportSuccess`,稳定覆盖
“调用 Flush 前队列已被后台线程清空”的场景
- 验证结果:
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-restore --disable-build-servers --filter "FullyQualifiedName~AsyncLogAppenderTests"`
- 结果:`15 Passed``0 Failed`
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-restore --disable-build-servers`
- 结果:`1607 Passed``0 Failed`
- 当前结论:
- PR #267 的 failed-test 信号不是纯粹的历史评论噪音,而是当前实现里仍存在的时序竞态
- 修复后该竞态已被稳定回归测试覆盖,当前 `GFramework.Core.Tests` 整包通过
- 下一步建议:
- 若继续 analyzer warning reduction 主题,恢复到 `MA0016` / `MA0002` 低风险批次
## 2026-04-21 — RP-014
### 阶段PR #267 review follow-up 收口RP-014