Skip to content
Merged
5 changes: 5 additions & 0 deletions modules/apps/cli/core/commands/core/McpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,11 @@ internal static async Task<int> WithHostAsync(Func<IServiceProvider, Task<int>>
// 历史注释"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<JoinCode.App.Modules.McpPluginBootstrapHandle>() is { } bootstrap) {
await bootstrap.Completion.WaitAsync(ct).ConfigureAwait(false);
}
return await action(host.Services).ConfigureAwait(false);
}

Expand Down
2 changes: 2 additions & 0 deletions modules/apps/engine/JoinCode.Engine.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
<InternalsVisibleTo Include="JoinCode.Integration.Tests" />
<InternalsVisibleTo Include="JoinCode.MockServer.E2E.Tests" />
<InternalsVisibleTo Include="NativeTui.Host" />
<!-- CLI 单次命令等待插件后台链(McpPluginBootstrapHandle.Completion — 工具表补齐守卫) -->
<InternalsVisibleTo Include="jcc" />
</ItemGroup>

<ItemGroup>
Expand Down
7 changes: 5 additions & 2 deletions modules/apps/engine/app/modules/McpPluginBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,11 @@ public void Run(Func<CancellationToken, Task> background)
}

/// <summary>插件就绪完成信号(邮箱/通知语义)— Run 前为已完成 Task(无可等之物);
/// Run 后为后台链 Task(八插件加载 + 就绪钩子全部完成)。消费方(E2E/宿主)等此信号
/// 确认工具注册就绪,替代时间性等待(Task.Delay 固定延时在 CI 快慢机上均不可靠)。</summary>
/// Run 后为后台链 Task(八插件加载 + 就绪钩子全部完成)。消费方等此信号确认工具注册就绪,
/// 替代时间性等待(Task.Delay 固定延时在 CI 快慢机上均不可靠)。两类消费方:
/// E2E/宿主等就绪;CLI 单次命令(gh/mcp_call/mcp_list)在查询工具前等待 —
/// 后台化后单次进程不等插件加载完成即查 registry,工具表只剩同步注册的 15 个(gh_pr_* 全缺失);
/// 长驻宿主(GUI/TUI/交互会话)不等待 — 保留启动开销优化收益。</summary>
public Task Completion => _background;

/// <summary>收尾 — 取消(尽力加速断链)→ 无超时等待链完成(链恒成功语义由 Bootstrap 保证,兜底吞);幂等。</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,24 @@ public async Task FirstAttemptSucceeds_ReturnsImmediately() {

/// <summary>
/// 首次超时,重试后成功 — CallCount>=2。
/// <para>flaky 根治(AGENTS 规范:重试16次+延长窗口):本机全量并行高压下 Actor 邮箱处理延迟
/// 可超过单次 100ms 超时 → 多轮超时后返回 success-at-3/4 撞死精确断言(8 轮全量 3 红实证)。
/// 修复:单次超时放宽 200ms + 结果改前缀断言(第几次成功是实现细节,契约是"重试后成功")
/// + 外层 Retry16(Actor 彻底饿死才判红)。</para>
/// </summary>
[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)");
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,18 @@ public async Task DiagnosticsEnabled关闭时_不记录诊断() {
LockRegistry.SetDiagnosticsEnabled(false);
var messages = new ConcurrentQueue<string>();
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]
Expand All @@ -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>"})");
}

/// <summary>单轮死锁检测场景(两个线程经后台扫描路径互相等待)——返回检测报告字符串表示本轮命中;null=本轮未检测到</summary>
private static async Task<string?> RunDeadlockRoundAsync(int attempt) {
LockRegistry.SetWaitTimeoutThreshold(TimeSpan.FromMilliseconds(100));
LockRegistry.StartBackgroundScan(TimeSpan.FromMilliseconds(50));
var messages = new ConcurrentQueue<string>();
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();
Expand All @@ -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;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public async Task AckAsync_DecreasesPendingCount() {
}

[Fact]
[Trait("Category", "Flaky")]
public async Task RecoverAsync_ReplaysPendingMessages() {
var store = new InMemoryPersistentStore<string>();
await using var actor = new SimpleTestActor();
Expand All @@ -38,12 +39,16 @@ public async Task RecoverAsync_ReplaysPendingMessages() {
await mailbox.PersistentSendAsync("msg-2");
await mailbox.PersistentSendAsync("msg-3");

var processed = new List<string>();
actor.OnMessage = msg => processed.Add(msg);
var processed = new System.Collections.Concurrent.ConcurrentQueue<string>();
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" });
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ public class TransportE2ETest {
try { return await source.FirstOrDefaultAsync(cts.Token); } catch (OperationCanceledException) { return null; }
}

/// <summary>
/// 等待 host 的已连接进程表收敛到包含全部期望 PID(状态收敛轮询,非固定延迟祈祷)。
/// BroadcastAsync 只对「此刻已入 _clientConnections」的 slave 发包(fire-and-forget 无回放),
/// 而 slave StartAsync 返回 ≠ 握手完成入表 — 固定 Task.Delay 在慢机(CI)上不够,
/// 曾致 BusTransport_Broadcast_MultipleSlavesReceive slave2 收不到广播(599/600 flaky)。
/// </summary>
private static async Task WaitForConnectedAsync(
BusTransport host, IReadOnlyList<string> 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() {
Expand Down Expand Up @@ -161,33 +175,48 @@ 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);

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<byte>(Encoding.UTF8.GetBytes("broadcast-all"));
await host.BroadcastAsync(data);

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;
}
}

/// <summary>帧摘要(null 显式标注)— 断言失败现场快照用</summary>
private static string DescribeFrame(TransportFrame? frame) =>
frame is null ? "<null>" : $"{frame.SourceProcessId}:{Encoding.UTF8.GetString(frame.Data.Span)}";
}
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ public static async ValueTask<DecodedMessage> ReadAsync(Stream stream, Cancellat
return new DecodedMessage(type, sourcePid, targetPid, payload);
}

/// <summary>持续从流异步读取消息序列,直到流结束或取消。</summary>
/// <summary>持续从流异步读取消息序列,直到流结束、取消或 IO 错误(对端关闭/重置以 IOException 呈现,帧错位以 InvalidDataException 呈现 — 均按流终止处理而非炸调用方)。</summary>
/// <param name="stream">数据流。</param>
/// <param name="ct">取消令牌。</param>
public static async IAsyncEnumerable<DecodedMessage> ReadStreamAsync(
Expand All @@ -369,6 +369,7 @@ public static async IAsyncEnumerable<DecodedMessage> 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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,19 +188,27 @@ public async Task DownloadUpdateAsync_WithProgress_ReportsProgress() {
Sha256 = sha256,
SizeBytes = content.Length
};
var progressReports = new List<UpdateDownloadProgress>();
var progress = new Progress<UpdateDownloadProgress>(p => progressReports.Add(p));
// 内联同步 IProgress(禁用 Progress<T>):Progress<T> 回调经同步上下文异步投递,
// DownloadUpdateAsync 返回时末条进度可能尚未执行 → 断言停在块边界偶发红(CI run 39629867210 实证:
// 期望 246760 实收 163840)。内联 Report 在下载线程直接调用,返回后队列必然完整
var progressReports = new System.Collections.Concurrent.ConcurrentQueue<UpdateDownloadProgress>();
IProgress<UpdateDownloadProgress> progress = new InlineProgress<UpdateDownloadProgress>(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);
}

/// <summary>同步内联 IProgress — Report 在调用线程直接执行回调(无异步投递竞态,测试确定性等待用)</summary>
private sealed class InlineProgress<T>(Action<T> report) : IProgress<T> {
public void Report(T value) => report(value);
}

[Fact]
public async Task ApplyUpdateAsync_NonExistentFile_ReturnsFailed() {
await using var service = new UpgradeService(_httpClient, _fs);
Expand Down
Loading