From c2dff29c34b1449d24e9ba2f426e35c2aec71a00 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 22:47:15 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20jcc=20gh=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E4=BA=94=E5=AE=88=E5=8D=AB=E4=BF=AE=E5=A4=8D=20+=20=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E9=AA=8C=E8=AF=81=20|=20=E5=86=B3=E7=AD=96:=20?= =?UTF-8?q?=E2=91=A0gh=5Fpr=5Fchecks/gh=5Fpr=5Fview=20=E5=8A=A0=20refresh?= =?UTF-8?q?=20=E5=8F=82=E6=95=B0=EF=BC=88IGhCacheService.EvictApiAsync=20?= =?UTF-8?q?=E9=80=9A=E9=81=93=EF=BC=8C=E7=BB=95=20detail=20TTL=E2=80=94?= =?UTF-8?q?=E2=80=94PR=20state=20=E7=BC=93=E5=AD=98=E8=AF=AF=E5=AF=BC/CI?= =?UTF-8?q?=20=E7=8A=B6=E6=80=81=E5=86=BB=E7=BB=93=E6=A0=B9=E5=9B=A0?= =?UTF-8?q?=EF=BC=89=E2=91=A1watch=20=E8=BD=AE=E8=AF=A2=E6=AF=8F=E8=BD=AE?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=20evict=EF=BC=88tracker.CacheDisabled=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=20AsyncLocal=EF=BC=8C=E7=9B=B4=E8=B0=83?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E7=94=B1=E6=AD=A4=E5=85=9C=E5=BA=95=EF=BC=89?= =?UTF-8?q?=E2=91=A2watch=20=E5=AE=8C=E6=88=90=E5=88=A4=E5=AE=9A=E6=81=92?= =?UTF-8?q?=E7=9C=9F=20bug=EF=BC=88Contains("=E8=BF=9B=E8=A1=8C=E4=B8=AD")?= =?UTF-8?q?=20=E6=94=B9=E7=B2=BE=E7=A1=AE=E5=AD=90=E4=B8=B2=20",=200=20?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E4=B8=AD,"=EF=BC=8Cfail-fast=20=E5=90=8C?= =?UTF-8?q?=E4=BF=AE=E2=80=94=E2=80=94=E5=8E=9F=E5=88=A4=E5=AE=9A=E6=B0=B8?= =?UTF-8?q?=E4=B8=8D=E6=8F=90=E5=89=8D=E8=BF=94=E5=9B=9E=EF=BC=89=E2=91=A3?= =?UTF-8?q?CLI=20=E5=8D=95=E6=AC=A1=E5=91=BD=E4=BB=A4=E7=AD=89=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=90=8E=E5=8F=B0=E9=93=BE=20Completion=EF=BC=88?= =?UTF-8?q?=E5=85=AB=E6=8F=92=E4=BB=B6=E5=90=8E=E5=8F=B0=E5=8C=96=E5=90=8E?= =?UTF-8?q?=20registry=20=E5=90=8C=E6=AD=A5=E4=BB=85=2015=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E3=80=81gh=5Fpr=5F*=20=E5=85=A8=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=97=A7=20exe=20=E4=B8=BA=20#439=20?= =?UTF-8?q?=E5=89=8D=E7=BC=96=E8=AF=91=E6=95=85=E6=9C=AA=E6=9A=B4=E9=9C=B2?= =?UTF-8?q?=EF=BC=8C=E6=9C=AC=E6=AC=A1=E9=83=A8=E7=BD=B2=E5=90=8E=E7=8E=B0?= =?UTF-8?q?=E5=BD=A2=EF=BC=89=E2=91=A4CLI=20fake=20=E5=8A=A0=20CallLog=20?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E8=AE=BE=E6=96=BD=E3=80=82mcp.tests=201038?= =?UTF-8?q?=20+=20Host.Tests=201371=20=E7=BB=BF=EF=BC=9B=E7=9C=9F=E6=9C=BA?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=20--refresh=20=E5=8F=AF=E7=94=A8=20+=20mcp?= =?UTF-8?q?=5Flist=20528=20+=20PR=20442=20=E9=87=8D=E8=AF=95=2027/27=20?= =?UTF-8?q?=E7=BB=BF=E5=B7=B2=E5=90=88=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/cli/core/commands/core/McpCommand.cs | 5 ++ modules/apps/engine/JoinCode.Engine.csproj | 2 + .../engine/app/modules/McpPluginBootstrap.cs | 7 +- .../GitHubToolHandlersRefreshEvictionTests.cs | 77 +++++++++++++++++++ .../core/a_to_t/GitHubToolHandlersTests.cs | 4 + .../mcp/git_hub/api/CacheGitHubApiClient.cs | 13 ++++ .../git_hub/handlers/GitHubToolHandlers.Pr.cs | 27 +++++-- 7 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersRefreshEvictionTests.cs diff --git a/modules/apps/cli/core/commands/core/McpCommand.cs b/modules/apps/cli/core/commands/core/McpCommand.cs index 8836943a2..d42adeea2 100644 --- a/modules/apps/cli/core/commands/core/McpCommand.cs +++ b/modules/apps/cli/core/commands/core/McpCommand.cs @@ -241,6 +241,11 @@ internal static async Task WithHostAsync(Func> // 历史注释"host.Dispose() 调 Environment.Exit(210)"已过时:退出码 210 在代码中不存在, // 且 ExecuteServeAsync 已用 await using var appHost 验证 DisposeAsync 安全。 await using var host = await BuildHostAsync(vendor, model, ct).ConfigureAwait(false); + // 单次命令等待插件后台链完成:八插件加载后台化后 registry 同步可见仅 15 个工具(gh_pr_* 全缺失), + // gh/mcp_call/mcp_list 查询前等 Completion 补齐工具表(长驻宿主不等待,保留启动优化收益) + if (host.Services.GetService() is { } bootstrap) { + await bootstrap.Completion.WaitAsync(ct).ConfigureAwait(false); + } return await action(host.Services).ConfigureAwait(false); } diff --git a/modules/apps/engine/JoinCode.Engine.csproj b/modules/apps/engine/JoinCode.Engine.csproj index adf8faf08..dad40df3c 100644 --- a/modules/apps/engine/JoinCode.Engine.csproj +++ b/modules/apps/engine/JoinCode.Engine.csproj @@ -34,6 +34,8 @@ + + diff --git a/modules/apps/engine/app/modules/McpPluginBootstrap.cs b/modules/apps/engine/app/modules/McpPluginBootstrap.cs index 6d3103244..49b61a753 100644 --- a/modules/apps/engine/app/modules/McpPluginBootstrap.cs +++ b/modules/apps/engine/app/modules/McpPluginBootstrap.cs @@ -87,8 +87,11 @@ public void Run(Func background) } /// 插件就绪完成信号(邮箱/通知语义)— Run 前为已完成 Task(无可等之物); - /// Run 后为后台链 Task(八插件加载 + 就绪钩子全部完成)。消费方(E2E/宿主)等此信号 - /// 确认工具注册就绪,替代时间性等待(Task.Delay 固定延时在 CI 快慢机上均不可靠)。 + /// Run 后为后台链 Task(八插件加载 + 就绪钩子全部完成)。消费方等此信号确认工具注册就绪, + /// 替代时间性等待(Task.Delay 固定延时在 CI 快慢机上均不可靠)。两类消费方: + /// E2E/宿主等就绪;CLI 单次命令(gh/mcp_call/mcp_list)在查询工具前等待 — + /// 后台化后单次进程不等插件加载完成即查 registry,工具表只剩同步注册的 15 个(gh_pr_* 全缺失); + /// 长驻宿主(GUI/TUI/交互会话)不等待 — 保留启动开销优化收益。 public Task Completion => _background; /// 收尾 — 取消(尽力加速断链)→ 无超时等待链完成(链恒成功语义由 Bootstrap 保证,兜底吞);幂等。 diff --git a/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersRefreshEvictionTests.cs b/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersRefreshEvictionTests.cs new file mode 100644 index 000000000..4b43fb86d --- /dev/null +++ b/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersRefreshEvictionTests.cs @@ -0,0 +1,77 @@ +namespace Mcp.Tests; + +/// +/// gh 工具 refresh 强制刷新测试 — 缺陷修复守卫: +/// pr checks / pr view 此前无 refresh 参数(CLI --refresh 报未知选项),且 watch 轮询与普通查询 +/// 均命中 API 缓存(detail 10min)导致状态冻结(PR 已合并仍 open / run 已 cancelled 仍 pending)。 +/// 修复 = refresh=true 时 evict API 缓存 key 后重拉。 +/// +public sealed partial class GitHubToolHandlersTests { + /// 构造带内存 KV 缓存的 handler(真实走 GhCacheService 路径,非 NoOp) + private static (GitHubToolHandlers Handler, FakeGitHubApiClient Api) CreateCachedHandler() { + var api = new FakeGitHubApiClient(); + var handler = new GitHubToolHandlers(new FakeDownloader(), new InMemoryFileSystem(), api, + git: null, logger: NullLogger.Instance, kvStore: new InMemoryKvStore()); + return (handler, api); + } + + [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 测试断言取首个文本非空")] + [Fact] + public async Task PrChecks_WithoutRefresh_HitsApiCache() { + var (handler, api) = CreateCachedHandler(); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"head":{"sha":"abc1"}}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"check_runs":[{"name":"build","conclusion":"success"}]}""" }); + var first = await handler.GhPrChecksAsync("42", common: new GitHubCommonOptions { Repo = "owner/repo" }); + first.GetFirstText()!.Should().Contain("1 通过"); + + // 换新响应但缓存 TTL 内 — 不带 refresh 应命中缓存不消费队列 + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"head":{"sha":"abc1"}}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"check_runs":[{"name":"build","conclusion":"failure"}]}""" }); + var second = await handler.GhPrChecksAsync("42", common: new GitHubCommonOptions { Repo = "owner/repo" }); + second.GetFirstText()!.Should().Contain("1 通过", "缓存命中返回旧状态(TTL 内)"); + } + + [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 测试断言取首个文本非空")] + [Fact] + public async Task PrChecks_WithRefresh_EvictsCacheAndRefetches() { + var (handler, api) = CreateCachedHandler(); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"head":{"sha":"abc1"}}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"check_runs":[{"name":"build","conclusion":"success"}]}""" }); + await handler.GhPrChecksAsync("42", common: new GitHubCommonOptions { Repo = "owner/repo" }); + + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"head":{"sha":"abc1"}}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"check_runs":[{"name":"build","conclusion":"failure"}]}""" }); + var refreshed = await handler.GhPrChecksAsync("42", refresh: true, common: new GitHubCommonOptions { Repo = "owner/repo" }); + refreshed.GetFirstText()!.Should().Contain("1 失败", "refresh=true 应 evict 缓存并重拉最新 check-runs"); + } + + [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 测试断言取首个文本非空")] + [Fact] + public async Task PrView_WithRefresh_EvictsCacheAndRefetches() { + var (handler, api) = CreateCachedHandler(); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"number":42,"state":"open","title":"t"}""" }); + var first = await handler.GhPrViewAsync("42", common: new GitHubCommonOptions { Repo = "owner/repo" }); + first.GetFirstText()!.Should().Contain("open"); + + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"number":42,"state":"closed","title":"t"}""" }); + var refreshed = await handler.GhPrViewAsync("42", refresh: true, common: new GitHubCommonOptions { Repo = "owner/repo" }); + refreshed.GetFirstText()!.Should().Contain("closed", "refresh=true 应 evict 缓存并重拉最新 PR 状态"); + } + + [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 测试断言取首个文本非空")] + [Fact] + public async Task PrChecks_WatchMode_EachRoundBypassesCache() { + // watch 轮询语义要求看最新状态 — 内层每轮强制 refresh,不得命中缓存冻结状态 + var (handler, api) = CreateCachedHandler(); + // 第 1 轮:pending;第 2 轮:success(interval 最小 1s,两轮各 enq 两个响应) + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"head":{"sha":"abc1"}}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"check_runs":[{"name":"build","status":"in_progress"}]}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"head":{"sha":"abc1"}}""" }); + api.EnqueueResponse(new GitHubApiResponse { Success = true, StatusCode = 200, Body = """{"check_runs":[{"name":"build","conclusion":"success"}]}""" }); + + var result = await handler.GhPrChecksAsync("42", watch: true, interval: 1, common: new GitHubCommonOptions { Repo = "owner/repo" }); + + var text = result.GetFirstText()!; + text.Should().Contain("1 通过", "watch 第二轮应看到最新 success 状态(轮询绕缓存+完成判定精确子串)"); + } +} diff --git a/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersTests.cs b/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersTests.cs index 7b2365405..fcfea7f07 100644 --- a/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersTests.cs +++ b/modules/mcp/mcp.tests/core/a_to_t/GitHubToolHandlersTests.cs @@ -1906,6 +1906,8 @@ public GitHubApiResponse NextResponse { private readonly GitHubApiResponse _default = new() { Success = true, StatusCode = 200, Body = "[]" }; public string? LastPath { get; private set; } public HttpMethod? LastMethod { get; private set; } + /// 调用序列诊断(refresh evict 测试用):每次 SendAsync 追加 "method path body前40" + public List CallLog { get; } = []; public string? LastBody { get; private set; } public IReadOnlyDictionary LastQuery { get; private set; } = new Dictionary(); public void EnqueueResponse(GitHubApiResponse response) => _responses.Enqueue(response); @@ -1914,6 +1916,8 @@ public GitHubApiResponse NextResponse { public Task SendAsync(HttpMethod method, string path, string? body = null, IReadOnlyDictionary? query = null, bool paginate = false, CancellationToken ct = default) { LastMethod = method; LastPath = path; + CallLog.Add(string.Create(System.Globalization.CultureInfo.InvariantCulture, + $"{method} {path} <- {(_responses.Count > 0 ? _responses.Peek().Body[..Math.Min(40, _responses.Peek().Body.Length)] : "")}")); LastBody = body; LastQuery = query ?? new Dictionary(); if (_responses.Count > 0) diff --git a/modules/mcp/mcp/git_hub/api/CacheGitHubApiClient.cs b/modules/mcp/mcp/git_hub/api/CacheGitHubApiClient.cs index 1cd7bd7ef..1c6512741 100644 --- a/modules/mcp/mcp/git_hub/api/CacheGitHubApiClient.cs +++ b/modules/mcp/mcp/git_hub/api/CacheGitHubApiClient.cs @@ -132,6 +132,8 @@ internal interface IGhCacheService { Task TryGetApiAsync(string path, IReadOnlyDictionary? query, CancellationToken ct); /// 写入 API 缓存 — 按端点自动选择 TTL(search 2min/detail 10min/default 5min) Task WriteApiAsync(string path, IReadOnlyDictionary? query, string body, CancellationToken ct); + /// 删除指定 API 缓存 — refresh 强刷通道(key 与 TryGet/Write 同构;PR state/check-runs 等 detail 类状态查询用) + Task EvictApiAsync(string path, IReadOnlyDictionary? query, CancellationToken ct); /// 尝试读取 GraphQL 查询缓存 — 命中返回 body,未命中返回 null Task TryGetGraphqlAsync(string body, CancellationToken ct); /// 写入 GraphQL 查询缓存 — TTL 5min @@ -238,6 +240,17 @@ public async Task WriteApiAsync(string path, IReadOnlyDictionary } } + /// + public async Task EvictApiAsync(string path, IReadOnlyDictionary? query, CancellationToken ct) { + if (_kvStore is null) return; + var keyBytes = BuildApiCacheKey(path, query); + try { + await _kvStore.DeleteAsync(keyBytes, ct).ConfigureAwait(false); + } catch (Exception ex) { + _logger?.LogWarning(ex, "gh API 缓存清除失败 path={Path}", path); + } + } + /// public async Task TryGetGraphqlAsync(string body, CancellationToken ct) { if (_kvStore is null) return null; diff --git a/modules/mcp/mcp/git_hub/handlers/GitHubToolHandlers.Pr.cs b/modules/mcp/mcp/git_hub/handlers/GitHubToolHandlers.Pr.cs index e37b2194a..1cda67fd8 100644 --- a/modules/mcp/mcp/git_hub/handlers/GitHubToolHandlers.Pr.cs +++ b/modules/mcp/mcp/git_hub/handlers/GitHubToolHandlers.Pr.cs @@ -16,10 +16,14 @@ public async Task GhPrViewAsync( [McpToolOptions] GitHubCommonOptions common, [McpToolParameter("comments=true 附带评论列表", Required = false)] bool? comments = null, [McpToolParameter("web=true 只返回 PR 浏览器 URL", Required = false)] bool? web = null, + [McpToolParameter("强制刷新缓存(默认 false,绕过 detail 10min TTL 直拉最新 state — 已知误导案例: PR 已合并仍显示 open)", Required = false)] bool? refresh = null, CancellationToken cancellationToken = default) => await ExecuteGhAsync(common.Repo, common.WorkingDir, cancellationToken, async (client, owner, repoName) => { var number = ParseNumberFromRef(pr_number); var apiPath = $"repos/{owner}/{repoName}/pulls/{number}"; + if (refresh == true && _cacheService is { } cache) { + await cache.EvictApiAsync(apiPath, null, cancellationToken).ConfigureAwait(false); + } if (web == true) { var prResult = await client.SendAsync(HttpMethod.Get, apiPath, ct: cancellationToken).ConfigureAwait(false); if (!prResult.Success) return Fail(prResult.Error); @@ -249,11 +253,12 @@ public async Task GhPrChecksAsync( [McpToolParameter("轮询间隔秒数(可选,默认 10)", Required = false)] int? interval = null, [McpToolParameter("有失败立即标记(可选)", Required = false)] bool? fail_fast = null, [McpToolParameter("最大显示 check 数量(可选,默认全部,pass 最多 5)", Required = false)] int? limit = null, + [McpToolParameter("强制刷新缓存(默认 false,绕过 detail TTL 直拉最新 check-runs — 状态冻结/已 rerun 时用)", Required = false)] bool? refresh = null, [McpToolOptions] GitHubCommonOptions? common = null, CancellationToken cancellationToken = default) => await ExecuteGhAsync(common?.Repo, common?.WorkingDir, cancellationToken, async (client, owner, repoName) => { if (watch == true) return await GhPrChecksWatchAsync(client, owner, repoName, pr_number, interval, fail_fast, required, limit, cancellationToken).ConfigureAwait(false); - return await GhPrChecksCoreAsync(client, owner, repoName, pr_number, fail_fast, required, common?.JsonFields, limit, cancellationToken).ConfigureAwait(false); + return await GhPrChecksCoreAsync(client, owner, repoName, pr_number, fail_fast, required, common?.JsonFields, limit, refresh == true, cancellationToken).ConfigureAwait(false); }).ConfigureAwait(false); /// @@ -266,11 +271,16 @@ private async Task GhPrChecksWatchAsync(IGitHubApiClient client, str if (tracker is not null) tracker.CacheDisabled = true; try { for (var i = 0; i < 120; i++) { - var checkResult = await GhPrChecksCoreAsync(client, owner, repoName, prNumber, failFast, required, null, limit, ct).ConfigureAwait(false); + // 每轮强制 evict(双保险:tracker.CacheDisabled 依赖 AsyncLocal 存在,直调/无 tracker 场景由此兜底) + var checkResult = await GhPrChecksCoreAsync(client, owner, repoName, prNumber, failFast, required, null, limit, forceRefresh: true, ct).ConfigureAwait(false); if (checkResult.IsError) return checkResult; var text = checkResult.GetFirstText() ?? ""; - if (!text.Contains("进行中")) return checkResult; - if (failFast == true && text.Contains("失败")) return checkResult; + // 汇总行由 Core 唯一构造("汇总: N 通过, N 失败, N 警告, N 进行中, N 跳过")—— + // 存量 bug:Contains("进行中")/"失败" 恒真("0 进行中" 也含该词)导致 watch 永不提前返回、fail-fast 恒触发; + // 修正为精确子串 ", 0 进行中," / ", 0 失败,"(完成=无进行中,有失败=失败数非 0) + if (text.Contains(", 0 进行中,", StringComparison.Ordinal)) return checkResult; + var hasFailure = !text.Contains(", 0 失败,", StringComparison.Ordinal); + if (failFast == true && hasFailure) return checkResult; await Task.Delay(delay, ct).ConfigureAwait(false); } return Ok("轮询超时,仍有 checks 进行中"); @@ -357,8 +367,11 @@ private static string MapCheckRollupState(string? status, string? conclusion) { /// GhPrChecks 核心逻辑 — 调 REST API 获取 check-runs,正确处理 skipping 语义(非失败),支持 required 过滤和 fail-fast 标记 /// [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 反射/延迟初始化/解析保证非空")] - private async Task GhPrChecksCoreAsync(IGitHubApiClient client, string owner, string repoName, string prNumber, bool? failFast, bool? required, string? json_fields, int? limit, CancellationToken ct) { + private async Task GhPrChecksCoreAsync(IGitHubApiClient client, string owner, string repoName, string prNumber, bool? failFast, bool? required, string? json_fields, int? limit, bool forceRefresh = false, CancellationToken ct = default) { var number = ParseNumberFromRef(prNumber); + if (forceRefresh && _cacheService is { } cache) { + await cache.EvictApiAsync($"repos/{owner}/{repoName}/pulls/{number}", null, ct).ConfigureAwait(false); + } var prResult = await client.SendAsync(HttpMethod.Get, $"repos/{owner}/{repoName}/pulls/{number}", ct: ct).ConfigureAwait(false); if (!prResult.Success) return Fail(prResult.Error); string? headSha; string? headRef; @@ -368,6 +381,10 @@ private async Task GhPrChecksCoreAsync(IGitHubApiClient client, stri headRef = prDetail?.Head?.Ref; } catch (Exception ex) { return Fail($"解析 PR head sha 失败: {ex.Message}"); } if (string.IsNullOrEmpty(headSha)) return Fail("无法从 PR 响应中解析 head.sha"); + if (forceRefresh && _cacheService is { } checksCache) { + await checksCache.EvictApiAsync($"repos/{owner}/{repoName}/commits/{headSha}/check-runs", + new Dictionary { ["per_page"] = "100" }, ct).ConfigureAwait(false); + } var checksResult = await client.SendAsync( HttpMethod.Get, $"repos/{owner}/{repoName}/commits/{headSha}/check-runs", From 7110b71709d9505b89d5be2d67b27daefd361eb0 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 23:11:59 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E4=BC=A0=E8=BE=93=E5=B9=BF=E6=92=AD?= =?UTF-8?q?=20E2E=20flaky=20=E6=A0=B9=E6=B2=BB=20|=20=E6=A0=B9=E5=9B=A0:?= =?UTF-8?q?=20BroadcastAsync=20=E5=8F=AA=E5=AF=B9=E5=B7=B2=E5=85=A5=20=5Fc?= =?UTF-8?q?lientConnections=20=E7=9A=84=20slave=20=E5=8F=91=E5=8C=85?= =?UTF-8?q?=EF=BC=88fire-and-forget=20=E6=97=A0=E5=9B=9E=E6=94=BE=EF=BC=89?= =?UTF-8?q?=EF=BC=8Cslave=20StartAsync=20=E8=BF=94=E5=9B=9E=E2=89=A0?= =?UTF-8?q?=E6=8F=A1=E6=89=8B=E5=AE=8C=E6=88=90=E5=85=A5=E8=A1=A8=EF=BC=8C?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=B8=89=E4=B8=AA=E5=9B=BA=E5=AE=9A=20Task.D?= =?UTF-8?q?elay(300)=20=E5=9C=A8=20CI=20=E6=85=A2=E6=9C=BA=E4=B8=8A?= =?UTF-8?q?=E4=B8=8D=E5=A4=9F=20=E2=86=92=20slave2=20=E6=94=B6=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=E5=B9=BF=E6=92=AD=EF=BC=88run=20885=20599/600=20?= =?UTF-8?q?=E5=A4=8D=E7=8E=B0=EF=BC=89=EF=BC=9B=E4=BF=AE=E5=A4=8D:=20?= =?UTF-8?q?=E7=A5=88=E7=A5=B7=E5=BC=8F=E5=BB=B6=E8=BF=9F=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=20GetConnectedProcesses=20=E7=8A=B6=E6=80=81=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E7=AD=89=E5=BE=85=EF=BC=88WaitForConnectedAsync=2050ms=20?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2+10s=20=E8=B6=85=E6=97=B6=EF=BC=8C=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E7=A6=81=E7=9D=A1=E8=A7=84=E8=8C=83=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E9=AA=8C=E8=AF=81:=20=E5=8D=95=E6=B5=8B=E8=AF=95=208/8=20?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF=E7=A8=B3=E5=AE=9A=20+=20TransportE2ETest=206?= =?UTF-8?q?/6=20=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/TransportE2ETest.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs b/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs index 4be895b80..547c87ead 100644 --- a/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs +++ b/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs @@ -13,6 +13,20 @@ public class TransportE2ETest { try { return await source.FirstOrDefaultAsync(cts.Token); } catch (OperationCanceledException) { return null; } } + /// + /// 等待 host 的已连接进程表收敛到包含全部期望 PID(状态收敛轮询,非固定延迟祈祷)。 + /// BroadcastAsync 只对「此刻已入 _clientConnections」的 slave 发包(fire-and-forget 无回放), + /// 而 slave StartAsync 返回 ≠ 握手完成入表 — 固定 Task.Delay 在慢机(CI)上不够, + /// 曾致 BusTransport_Broadcast_MultipleSlavesReceive slave2 收不到广播(599/600 flaky)。 + /// + private static async Task WaitForConnectedAsync( + BusTransport host, IReadOnlyList expectedPids, TimeSpan timeout) { + using var cts = new CancellationTokenSource(timeout); + while (!expectedPids.All(host.GetConnectedProcesses().Contains)) { + await Task.Delay(50, cts.Token); + } + } + [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 反射/延迟初始化/解析保证非空")] [Fact] public async Task BusTransport_HostSlave_MessageRoundTrip() { @@ -172,11 +186,10 @@ public async Task BusTransport_Broadcast_MultipleSlavesReceive() { await using var slave2 = new BusTransport(pipeName: pipeName, processId: slave2Pid); await host.StartAsync(); - await Task.Delay(300); await slave1.StartAsync(); - await Task.Delay(300); await slave2.StartAsync(); - await Task.Delay(300); + // 确定性等待两个 slave 握手入表(BroadcastAsync 只发已连接者,无回放 — 参见 WaitForConnectedAsync 注释) + await WaitForConnectedAsync(host, [slave1Pid, slave2Pid], TimeSpan.FromSeconds(10)); var data = new ReadOnlyMemory(Encoding.UTF8.GetBytes("broadcast-all")); await host.BroadcastAsync(data); From 9d1e9f00c6c6a5a939a5686296d15014d21abdfd Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 23:53:52 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20async=5Flock.tests=20=E6=97=B6?= =?UTF-8?q?=E5=BA=8F=20flaky=20=E6=97=8F=E6=A0=B9=E6=B2=BB=EF=BC=88?= =?UTF-8?q?=E5=90=8C=E5=B7=A5=E7=A8=8B=204=20=E5=A4=84=EF=BC=89|=20?= =?UTF-8?q?=E6=A0=B9=E5=9B=A0:=20LockRegistry=20=E9=9D=99=E6=80=81?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E8=B7=A8=E6=B5=8B=E8=AF=95=E4=BA=92=E8=B8=A9?= =?UTF-8?q?=20+=20=E5=9B=BA=E5=AE=9A=E7=AA=97=E5=8F=A3=E7=A5=88=E7=A5=B7?= =?UTF-8?q?=E5=BC=8F=E7=AD=89=E5=BE=85=E5=9C=A8=E6=9C=AC=E6=9C=BA=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E5=B9=B6=E8=A1=8C=E9=AB=98=E5=8E=8B=E4=B8=8B=E4=B8=8D?= =?UTF-8?q?=E5=A4=9F=EF=BC=9B=E4=BF=AE=E5=A4=8D:=20=E2=91=A0=E6=AD=BB?= =?UTF-8?q?=E9=94=81=E6=A3=80=E6=B5=8B=5FRetry16Times=EF=BC=8816=C3=9715s?= =?UTF-8?q?=20=E7=AA=97=E5=8F=A3+=E6=AF=8F=E8=BD=AE=20ClearForTesting=20?= =?UTF-8?q?=E5=B9=B2=E5=87=80=E7=8A=B6=E6=80=81=EF=BC=89=E2=91=A1Diagnosti?= =?UTF-8?q?csEnabled=E5=85=B3=E9=97=AD=E6=97=B6=EF=BC=88=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E6=94=B9=E6=9C=AC=E9=94=81=E5=90=8D=E8=BF=87=E6=BB=A4=E6=8A=97?= =?UTF-8?q?=E5=B9=B6=E8=A1=8C=E7=B1=BB=20sink=20=E5=99=AA=E9=9F=B3+?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=8A=A0=20finally=EF=BC=89=E2=91=A2AskWithR?= =?UTF-8?q?etry=5FRetry16Times=EF=BC=88=E5=8D=95=E6=AC=A1=E8=B6=85?= =?UTF-8?q?=E6=97=B6=20100=E2=86=92200ms+=E7=BB=93=E6=9E=9C=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E6=96=AD=E8=A8=80+16=20=E9=87=8D=E8=AF=95=EF=BC=89?= =?UTF-8?q?=E2=91=A3PersistentMailbox=20=E5=9B=9E=E6=94=BE=EF=BC=88Task.De?= =?UTF-8?q?lay(100)=20=E7=A5=88=E7=A5=B7=E5=BC=8F=E6=94=B9=20SpinUntil=20?= =?UTF-8?q?=E6=94=B6=E6=95=9B+10s=20=E7=AA=97=E5=8F=A3=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E9=AA=8C=E8=AF=81:=208=20=E8=BD=AE=E5=85=A8=E9=87=8F=E5=8E=8B?= =?UTF-8?q?=E6=B5=8B=208/8=20=E7=BB=BF=EF=BC=88=E4=BF=AE=E5=A4=8D=E5=89=8D?= =?UTF-8?q?=204=20=E7=BA=A2=E6=AC=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../actor/AskWithRetryTest.cs | 22 ++++-- .../lock/AsyncLockDiagnosisTests.cs | 72 ++++++++++++++----- .../mailbox/PersistentMailboxTest.cs | 13 ++-- 3 files changed, 78 insertions(+), 29 deletions(-) diff --git a/modules/async_primitives/async_lock.tests/actor/AskWithRetryTest.cs b/modules/async_primitives/async_lock.tests/actor/AskWithRetryTest.cs index d322661c8..1125558dd 100644 --- a/modules/async_primitives/async_lock.tests/actor/AskWithRetryTest.cs +++ b/modules/async_primitives/async_lock.tests/actor/AskWithRetryTest.cs @@ -18,14 +18,24 @@ public async Task FirstAttemptSucceeds_ReturnsImmediately() { /// /// 首次超时,重试后成功 — CallCount>=2。 + /// flaky 根治(AGENTS 规范:重试16次+延长窗口):本机全量并行高压下 Actor 邮箱处理延迟 + /// 可超过单次 100ms 超时 → 多轮超时后返回 success-at-3/4 撞死精确断言(8 轮全量 3 红实证)。 + /// 修复:单次超时放宽 200ms + 结果改前缀断言(第几次成功是实现细节,契约是"重试后成功") + /// + 外层 Retry16(Actor 彻底饿死才判红)。 /// [Fact] - public async Task FirstAttemptTimesOut_RetriesAndSucceeds() { - await using var actor = new RetryDemoActor(); - actor.SetSuccessAfter(1); - var result = await actor.AskWithRetryTestAsync(singleTimeoutMs: 100, maxRetries: 5); - result.Should().Be("success-at-2"); - actor.CallCount.Should().BeGreaterThanOrEqualTo(2); + [Trait("Category", "Flaky")] + public async Task FirstAttemptTimesOut_RetriesAndSucceeds_Retry16Times() { + for (var attempt = 0; attempt < 16; attempt++) { + await using var actor = new RetryDemoActor(); + actor.SetSuccessAfter(1); + var result = await actor.AskWithRetryTestAsync(singleTimeoutMs: 200, maxRetries: 5); + if (result.StartsWith("success-at-", StringComparison.Ordinal) + && actor.CallCount >= 2) { + return; + } + } + throw new Xunit.Sdk.XunitException("16次重试均失败: 首次超时后重试未能成功(每次都未拿到 success-at-* 或 CallCount<2)"); } /// diff --git a/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs b/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs index c96809aef..55a66c9a3 100644 --- a/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs +++ b/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs @@ -122,12 +122,18 @@ public async Task DiagnosticsEnabled关闭时_不记录诊断() { LockRegistry.SetDiagnosticsEnabled(false); var messages = new ConcurrentQueue(); LockRegistry.SetDiagnosticSink(messages.Enqueue); - using var lk = new AsyncLock("disabled-test"); - using (await lk.TryLockAsync() ?? throw new System.TimeoutException($"锁 '{lk.Name}' 等待超时")) { - await Task.Delay(60); + try { + using var lk = new AsyncLock("disabled-test"); + using (await lk.TryLockAsync() ?? throw new System.TimeoutException($"锁 '{lk.Name}' 等待超时")) { + await Task.Delay(60); + } + // 断言本锁无诊断(而非队列全空):DiagnosticSink 是静态全局,并行测试类的锁诊断 + // 也会进本队列 —— "全空"断言在跨类并行下偶发红(flaky 同族,LockRegistry 静态互踩) + messages.Should().NotContain(m => m.Contains("disabled-test"), + "诊断关闭时本锁不应产生任何告警"); + } finally { + LockRegistry.SetDiagnosticsEnabled(true); } - messages.Should().BeEmpty("诊断关闭时不应产生任何告警"); - LockRegistry.SetDiagnosticsEnabled(true); } [Fact] @@ -142,14 +148,38 @@ public async Task Lock同步_基本互斥() { [Fact] [Trait("Category", "Timing")] - public Task 死锁检测_两个线程互相等待时自动检测() { + [Trait("Category", "Flaky")] + public async Task 死锁检测_两个线程互相等待时自动检测_Retry16Times() { + // flaky 根治(AGENTS 规范:重试16次+延长窗口):后台 Timer 扫描在高负载(CI/本机全量并行)下回调 + // 延迟可远超单次窗口 —— 单轮 15s 检测窗口 × 16 轮重试,全部失败才判红(run 885 及本机全量偶发红实证)。 + // 每轮完整恢复 LockRegistry 全局(threshold/sink/扫描),防状态泄漏到下一轮。 var originalWaitThreshold = LockRegistry.WaitTimeoutThreshold; + string? lastReport = null; + try { + for (var attempt = 0; attempt < 16; attempt++) { + var detected = await RunDeadlockRoundAsync(attempt); + if (detected is { } report) { + return; + } + lastReport = LockRegistry.LastDeadlockReport; + } + } finally { + LockRegistry.StopBackgroundScan(); + LockRegistry.SetWaitTimeoutThreshold(originalWaitThreshold); + LockRegistry.SetDiagnosticSink(null); + } + throw new Xunit.Sdk.XunitException( + $"16次重试均失败: 后台扫描在 16×15s 窗口内未检测到互相等待死锁(最后一次报告: {lastReport ?? ""})"); + } + + /// 单轮死锁检测场景(两个线程经后台扫描路径互相等待)——返回检测报告字符串表示本轮命中;null=本轮未检测到 + private static async Task RunDeadlockRoundAsync(int attempt) { LockRegistry.SetWaitTimeoutThreshold(TimeSpan.FromMilliseconds(100)); LockRegistry.StartBackgroundScan(TimeSpan.FromMilliseconds(50)); var messages = new ConcurrentQueue(); LockRegistry.SetDiagnosticSink(messages.Enqueue); - using var lockA = new AsyncLock("deadlock-A", TimeSpan.FromSeconds(3)); - using var lockB = new AsyncLock("deadlock-B", TimeSpan.FromSeconds(3)); + using var lockA = new AsyncLock($"deadlock-A-{attempt}", TimeSpan.FromSeconds(3)); + using var lockB = new AsyncLock($"deadlock-B-{attempt}", TimeSpan.FromSeconds(3)); using var barrier = new Barrier(2); using var t1Done = new ManualResetEventSlim(); @@ -174,20 +204,24 @@ public Task 死锁检测_两个线程互相等待时自动检测() { t1.Start(); t2.Start(); - var detected = System.Threading.SpinWait.SpinUntil(() => LockRegistry.DeadlockDetected, TimeSpan.FromSeconds(5)); - detected.Should().BeTrue("两个线程互相等待对方持有的锁应被自动检测为死锁(5s 轮询等待,容忍 CI 调度延迟)"); - LockRegistry.LastDeadlockReport.Should().Contain("DEADLOCK-DETECTED"); - LockRegistry.LastDeadlockReport.Should().Contain("deadlock-A"); - LockRegistry.LastDeadlockReport.Should().Contain("deadlock-B"); - messages.Should().Contain(m => m.Contains("DEADLOCK-DETECTED")); + // 窗口 15s:CI 高负载 Timer 回调延迟留足余量(原 5s 曾被压满) + var detected = System.Threading.SpinWait.SpinUntil(() => LockRegistry.DeadlockDetected, TimeSpan.FromSeconds(15)); + var report = detected ? LockRegistry.LastDeadlockReport : null; + if (detected) { + report.Should().Contain("DEADLOCK-DETECTED"); + report.Should().Contain(lockA.Name); + report.Should().Contain(lockB.Name); + messages.Should().Contain(m => m.Contains("DEADLOCK-DETECTED")); + } t1Done.Wait(8000); t2Done.Wait(8000); - - LockRegistry.StopBackgroundScan(); - LockRegistry.SetWaitTimeoutThreshold(originalWaitThreshold); - LockRegistry.SetDiagnosticSink(null); - return Task.CompletedTask; + // 本轮收尾:清检测标志/报告/flow 身份(internal ClearForTesting 先例),让下一轮从干净状态开始; + // 锁对象带 attempt 后缀避免注册表残留互相干扰。ClearForTesting 内部会 StopBackgroundScan, + // 下一轮开头的 StartBackgroundScan 重新拉起 + LockRegistry.ClearForTesting(); + await Task.CompletedTask; + return report; } /// diff --git a/modules/async_primitives/async_lock.tests/mailbox/PersistentMailboxTest.cs b/modules/async_primitives/async_lock.tests/mailbox/PersistentMailboxTest.cs index e06e8f924..835f459b7 100644 --- a/modules/async_primitives/async_lock.tests/mailbox/PersistentMailboxTest.cs +++ b/modules/async_primitives/async_lock.tests/mailbox/PersistentMailboxTest.cs @@ -29,6 +29,7 @@ public async Task AckAsync_DecreasesPendingCount() { } [Fact] + [Trait("Category", "Flaky")] public async Task RecoverAsync_ReplaysPendingMessages() { var store = new InMemoryPersistentStore(); await using var actor = new SimpleTestActor(); @@ -38,12 +39,16 @@ public async Task RecoverAsync_ReplaysPendingMessages() { await mailbox.PersistentSendAsync("msg-2"); await mailbox.PersistentSendAsync("msg-3"); - var processed = new List(); - actor.OnMessage = msg => processed.Add(msg); + var processed = new System.Collections.Concurrent.ConcurrentQueue(); + actor.OnMessage = msg => processed.Enqueue(msg); await mailbox.RecoverAsync(); - await Task.Delay(100); - + // 状态收敛等待(禁睡规范):回放经 Actor 邮箱异步处理,固定 Task.Delay(100) 在本机 + // 全量并行高压下不够 → 偶发丢断言(8 轮全量 1 红实证);10s 窗口轮询到三条齐即返回 + var converged = System.Threading.SpinWait.SpinUntil( + () => processed.Count >= 3, + TimeSpan.FromSeconds(10)); + converged.Should().BeTrue("10s 窗口内应回放完成(本机并行高压兜底)"); processed.Should().Contain(new[] { "msg-1", "msg-2", "msg-3" }); } From 2915fb308a04bbc70674d1921a59e27f2069b412 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 22 Oct 2026 00:21:16 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=E4=B8=8B=E8=BD=BD=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E6=B5=8B=E8=AF=95=20flaky=20=E6=A0=B9?= =?UTF-8?q?=E6=B2=BB=20|=20=E6=A0=B9=E5=9B=A0:=20Progress=20=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E7=BB=8F=E5=90=8C=E6=AD=A5=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E6=8A=95=E9=80=92=EF=BC=8CDownloadUpdateAsyn?= =?UTF-8?q?c=20=E8=BF=94=E5=9B=9E=E6=97=B6=E6=9C=AB=E6=9D=A1=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=EF=BC=88=E5=85=A8=E9=87=8F=20246760=EF=BC=89=E5=B0=9A?= =?UTF-8?q?=E6=9C=AA=E6=89=A7=E8=A1=8C=20=E2=86=92=20=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E5=81=9C=E5=9C=A8=E5=9D=97=E8=BE=B9=E7=95=8C=20163840=EF=BC=88?= =?UTF-8?q?CI=20run=2039629867210=20=E5=AE=9E=E8=AF=81=20327/328=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E4=BF=AE=E5=A4=8D:=20=E6=8D=A2=20InlineProgress=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=86=85=E8=81=94=20IProgress=EF=BC=88Report?= =?UTF-8?q?=20=E5=9C=A8=E8=B0=83=E7=94=A8=E7=BA=BF=E7=A8=8B=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E6=89=A7=E8=A1=8C=EF=BC=8C=E8=BF=94=E5=9B=9E=E5=90=8E?= =?UTF-8?q?=E9=98=9F=E5=88=97=E5=BF=85=E7=84=B6=E5=AE=8C=E6=95=B4=EF=BC=89?= =?UTF-8?q?+=20ConcurrentQueue=20=E7=BA=BF=E7=A8=8B=E5=AE=89=E5=85=A8?= =?UTF-8?q?=EF=BC=9B=E9=AA=8C=E8=AF=81:=20=E5=8D=95=E6=B5=8B=E8=AF=95=2020?= =?UTF-8?q?/20=20=E5=8E=8B=E6=B5=8B=20+=20=E5=B7=A5=E7=A8=8B=E5=85=A8?= =?UTF-8?q?=E9=87=8F=20332/332=20=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/UpgradeServiceUpdateTests.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/modules/cognition/hands.api.tests/integration/UpgradeServiceUpdateTests.cs b/modules/cognition/hands.api.tests/integration/UpgradeServiceUpdateTests.cs index 1ac442533..a6c3abc1a 100644 --- a/modules/cognition/hands.api.tests/integration/UpgradeServiceUpdateTests.cs +++ b/modules/cognition/hands.api.tests/integration/UpgradeServiceUpdateTests.cs @@ -188,19 +188,27 @@ public async Task DownloadUpdateAsync_WithProgress_ReportsProgress() { Sha256 = sha256, SizeBytes = content.Length }; - var progressReports = new List(); - var progress = new Progress(p => progressReports.Add(p)); + // 内联同步 IProgress(禁用 Progress):Progress 回调经同步上下文异步投递, + // DownloadUpdateAsync 返回时末条进度可能尚未执行 → 断言停在块边界偶发红(CI run 39629867210 实证: + // 期望 246760 实收 163840)。内联 Report 在下载线程直接调用,返回后队列必然完整 + var progressReports = new System.Collections.Concurrent.ConcurrentQueue(); + IProgress progress = new InlineProgress(progressReports.Enqueue); var result = await service.DownloadUpdateAsync(entry, progress); result.Success.Should().BeTrue(); progressReports.Should().NotBeEmpty(); - progressReports[^1].BytesDownloaded.Should().Be(content.Length); + progressReports.Last().BytesDownloaded.Should().Be(content.Length); if (result.DownloadedPath is not null && _fs.FileExists(result.DownloadedPath)) _fs.DeleteFile(result.DownloadedPath); } + /// 同步内联 IProgress — Report 在调用线程直接执行回调(无异步投递竞态,测试确定性等待用) + private sealed class InlineProgress(Action report) : IProgress { + public void Report(T value) => report(value); + } + [Fact] public async Task ApplyUpdateAsync_NonExistentFile_ReturnsFailed() { await using var service = new UpgradeService(_httpClient, _fs); From 12e37e73aac1c0bff38e0e249089da4b20bb742b Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 22 Oct 2026 00:50:36 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E6=80=BB=E7=BA=BF=E5=B9=BF=E6=92=AD?= =?UTF-8?q?=20Linux=20=E7=A1=AE=E5=AE=9A=E6=80=A7=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=8F=96=E8=AF=81+=E5=81=A5=E5=A3=AE=E6=80=A7=20|=20=E6=A0=B9?= =?UTF-8?q?=E5=9B=A0:=20CI(run=2039634029671)=20599/600=20=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E7=BA=A2=20BusTransport=5FBroadcast=EF=BC=8C=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=20Windows=20=E6=81=92=E7=BB=BF=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E5=A4=8D=E7=8E=B0=EF=BC=88WSL=20=E4=B8=8D=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E5=A4=84=E7=BD=AE:=20=E2=91=A0ReadStreamAsyn?= =?UTF-8?q?c=20=E8=A1=A5=E6=8D=95=20IOException/InvalidDataException?= =?UTF-8?q?=EF=BC=88Linux=20UDS=20=E5=AF=B9=E7=AB=AF=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E5=B8=B8=E4=BB=A5=20IOException=20=E8=80=8C=E9=9D=9E=20EOS=20?= =?UTF-8?q?=E5=91=88=E7=8E=B0=EF=BC=8C=E5=B8=A7=E9=94=99=E4=BD=8D=E4=BB=A5?= =?UTF-8?q?=20InvalidDataException=20=E5=91=88=E7=8E=B0=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=8E=9F=E5=AE=9E=E7=8E=B0=E4=BC=9A=E6=B0=B8=E4=B9=85=E6=9D=80?= =?UTF-8?q?=E6=AD=BB=E8=AF=BB=E5=BE=AA=E7=8E=AF=E4=B8=94=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E4=B8=A2=E5=90=8E=E7=BB=AD=E6=B6=88=E6=81=AF=EF=BC=89=E2=91=A1?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=BC=80=20TransportDiagnostics+=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=8E=B0=E5=9C=BA=E5=BF=AB=E7=85=A7=EF=BC=88host=20?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E8=A1=A8+=E5=90=84=20slave=20=E5=AE=9E?= =?UTF-8?q?=E6=94=B6=E5=B8=A7=EF=BC=8C=E4=B8=8B=E6=AC=A1=20CI=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=20stderr=20=E5=85=A8=E8=BD=A8=E8=BF=B9=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E5=AE=9A=E4=BD=8D=EF=BC=89=E2=91=A2Trait=20Flaky=20?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=EF=BC=9B=E9=AA=8C=E8=AF=81:=20=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=206/6+6=20=E8=BD=AE=E5=BE=AA=E7=8E=AF=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A+=E5=85=A8=E9=87=8F=20600/600=20=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/TransportE2ETest.cs | 20 +++++++++++++++++-- .../transport/NamedPipeTransport.cs | 3 ++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs b/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs index 547c87ead..814928563 100644 --- a/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs +++ b/modules/async_primitives/async_lock.tests/transport/TransportE2ETest.cs @@ -175,12 +175,17 @@ await TestWaitHelper.WaitUntilAsync(async () => { [SuppressMessage("AotSafety", "JCC11003", Justification = "边界可空抑制: 反射/延迟初始化/解析保证非空")] [Fact] + [Trait("Category", "Flaky")] public async Task BusTransport_Broadcast_MultipleSlavesReceive() { var pipeName = $"e2e-bus-multi-{Guid.NewGuid():N}"; var hostPid = "600001"; var slave1Pid = "600002"; var slave2Pid = "600003"; + // Linux CI 确定性失败取证(本地 Windows 恒绿无法复现):开 TransportDiagnostics, + // 失败时 xUnit 会把 stderr 全轨迹(PIPE-ACCEPT/BUS 收发)带进失败输出 — 一次定位根因 + TransportDiagnostics.Enabled = true; + try { await using var host = new BusTransport(pipeName: pipeName, processId: hostPid); await using var slave1 = new BusTransport(pipeName: pipeName, processId: slave1Pid); await using var slave2 = new BusTransport(pipeName: pipeName, processId: slave2Pid); @@ -197,10 +202,21 @@ public async Task BusTransport_Broadcast_MultipleSlavesReceive() { var recv1 = await ReceiveWithTimeoutAsync(slave1.ReceiveAsync(), TimeSpan.FromSeconds(5)); var recv2 = await ReceiveWithTimeoutAsync(slave2.ReceiveAsync(), TimeSpan.FromSeconds(5)); - recv1.Should().NotBeNull("slave1 应收到广播"); + // 现场快照:广播后 host 连接表 + 各 slave 实收帧(失败时直接看丢在哪一环) + var scene = $"广播后host表=[{string.Join(",", host.GetConnectedProcesses())}] " + + $"slave1实收={DescribeFrame(recv1)} slave2实收={DescribeFrame(recv2)}"; + + recv1.Should().NotBeNull($"slave1 应收到广播;现场: {scene}"); Encoding.UTF8.GetString(recv1!.Data.Span).Should().Be("broadcast-all"); - recv2.Should().NotBeNull("slave2 应收到广播"); + recv2.Should().NotBeNull($"slave2 应收到广播;现场: {scene}"); Encoding.UTF8.GetString(recv2!.Data.Span).Should().Be("broadcast-all"); + } finally { + TransportDiagnostics.Enabled = false; + } } + + /// 帧摘要(null 显式标注)— 断言失败现场快照用 + private static string DescribeFrame(TransportFrame? frame) => + frame is null ? "" : $"{frame.SourceProcessId}:{Encoding.UTF8.GetString(frame.Data.Span)}"; } \ No newline at end of file diff --git a/modules/async_primitives/async_lock/transport/NamedPipeTransport.cs b/modules/async_primitives/async_lock/transport/NamedPipeTransport.cs index 84c25aae6..90e3e290a 100644 --- a/modules/async_primitives/async_lock/transport/NamedPipeTransport.cs +++ b/modules/async_primitives/async_lock/transport/NamedPipeTransport.cs @@ -358,7 +358,7 @@ public static async ValueTask ReadAsync(Stream stream, Cancellat return new DecodedMessage(type, sourcePid, targetPid, payload); } - /// 持续从流异步读取消息序列,直到流结束或取消。 + /// 持续从流异步读取消息序列,直到流结束、取消或 IO 错误(对端关闭/重置以 IOException 呈现,帧错位以 InvalidDataException 呈现 — 均按流终止处理而非炸调用方)。 /// 数据流。 /// 取消令牌。 public static async IAsyncEnumerable ReadStreamAsync( @@ -369,6 +369,7 @@ public static async IAsyncEnumerable ReadStreamAsync( try { msg = await ReadAsync(stream, ct).ConfigureAwait(false); } catch (OperationCanceledException) { yield break; } catch (EndOfStreamException) { yield break; } + catch (IOException) { yield break; } catch (InvalidDataException) { yield break; } yield return msg; } }