wenxiangchao
3 天以前 b544d5cccd76735e034c8055918a50927b437474
fix: 维护模式重复进入失败与按钮灰死 + Broker 并发验证用例

调试会话生命周期修复:
- DebugSessionManager.CloseSession 不再删除 _sessionOwnerTokens 缓存
令牌 TokenId 每次 new 都是新 GUID,删除后再次进入维护会创建新令牌,
导致 LayeredResourceManager.FindGrant 落空、旧租约永不释放、
arb.db 卡 Phase=Active 使第二次进入维护永久失败
- DeviceDebugTab.CloseSessionSilently 关闭后重新 OpenSession(ReadOnly)
并刷新按钮状态,修复退出维护后"进入维护控制"按钮变灰不可点击

运行环境修复:
- IntegrationTest.TryStartDeviceBroker 增加进程存在性检查
多实例各自拉起 DeviceBroker 会争抢同一命名管道与 arb.db,
使 Broker 物理驱动连接失败,报"设备 CAN-003 未连接,请先 CONNECT"

测试:
- BrokerE2ETests 新增 5 个并发与生命周期用例:
双客户端并发发送经 SessionGate 串行化、双 Production 租约竞争返回
ConflictHeld、租约 Acquire→Send→Release→再 Acquire 完整生命周期、
单客户端 5 次快速发送与 20 任务并发压力
- 全量单测 56/56 通过
4个文件已修改
205 ■■■■■ 已修改文件
Plugin.DeviceDebug/DeviceDebugTab.xaml.cs 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Model/DebugSessionManager.cs 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.IntegrationTest/MainWindow.xaml.cs 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/Aging.MvpTests/BrokerE2ETests.cs 187 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/DeviceDebugTab.xaml.cs
@@ -432,6 +432,11 @@
                    CurrentSession = null;
                    log.Info($"设备 {BoundDeviceId} 会话已关闭,维护租约已释放");
                }
                // 重新打开 ReadOnly 会话,让用户可以再次进入维护模式
                CurrentSession = _sessionManager.OpenSession(BoundDeviceId, "", "operator", CurrentEffectiveVersion);
                UpdateButtonStates();
                LoadOperationLogs();
            }
            catch (Exception ex)
            {
Plugin.DeviceDebug/Model/DebugSessionManager.cs
@@ -289,8 +289,10 @@
                        _resourceManager.ReleaseReservationAsync(
                            ownerToken, resourceKey, CancellationToken.None).GetAwaiter().GetResult();
                        _sessionOwnerTokens.Remove(sessionId);
                        log.Info($"已释放设备 {session.DeviceId} 的资源预约");
                        // 注意:不删除 _sessionOwnerTokens[sessionId]!
                        // 令牌必须保持稳定(TokenId 不变),否则下次进入维护时 new 出的新 Token
                        // 无法在 LayeredResourceManager._grantsByToken 中找到旧租约 → 释放落空 → 租约永久泄漏
                        log.Info($"已释放设备 {session.DeviceId} 的资源预约(令牌保留供复用)");
                    }
                    catch (Exception ex)
                    {
Plugin.IntegrationTest/MainWindow.xaml.cs
@@ -721,6 +721,13 @@
        {
            try
            {
                // 检查是否已有 DeviceBroker 进程在运行(避免多实例争抢管道/arb.db)
                if (System.Diagnostics.Process.GetProcessesByName("DeviceBroker").Length > 0)
                {
                    Log("[INFO] DeviceBroker 宿主已在运行,跳过启动");
                    return;
                }
                var model = new DeviceConfigModel();
                string dbPath = Path.Combine(_testDir, "data.db");
                if (!File.Exists(dbPath)) return;
tests/Aging.MvpTests/BrokerE2ETests.cs
@@ -1,6 +1,7 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Aging.Infrastructure.Broker;
@@ -212,6 +213,192 @@
            _clientLease.Release(grant, "test-end");
        }
        // ==================== 并发与生命周期 ====================
        /// <summary>单客户端连续 5 次快速发送:验证 SessionGate 串行化不丢命令、租约不被误释放。</summary>
        [Fact]
        public async Task SingleClient_RapidSend5Times_AllSucceed_LeaseStaysActive()
        {
            var grant = GrabLease();
            var hash = DeviceConfigHash.Compute(_dataDb);
            using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
            await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
            for (int i = 0; i < 5; i++)
            {
                var tx = await driver.SendCommandAsync($"SEND:0x{i:X3}:AA", CancellationToken.None);
                Assert.Contains("OK:TX", tx);
            }
            // 5 次操作后租约仍 Active(未被意外释放或过期)
            Assert.Equal(LeasePhase.Active, _clientLease.PhaseOf(DevId));
            _clientLease.Release(grant, "test-end");
        }
        /// <summary>
        /// 两个 Broker 代理驱动同时持有同一设备的活跃租约,并发发送各 5 次。
        /// 由于 arb.db 以 ResourceKey 为主键只允许一行 Active,第二个租约通过直接 SQL 插入模拟
        /// (MVP 栅栏仅校验 Phase=Active + 未过期,不比对 OwnerId)。
        /// 验证 SessionGate(SemaphoreSlim=1) 将两个并发请求串行执行,两者均成功。
        /// </summary>
        [Fact]
        public async Task TwoClients_ConcurrentSend_BothSucceed_SessionGateSerializes()
        {
            // 客户端 A:经正常 Acquire 获取 Production 租约
            var grantA = GrabLease();
            var hash = DeviceConfigHash.Compute(_dataDb);
            using var driverA = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
            await RetryAsync(() => driverA.ConnectAsync(CancellationToken.None));
            // 客户端 B:直接往 arb.db 插入一条 Maintenance Active 租约行,模拟另一个持有者
            InsertDirectLeaseRow(DevId, "concurrent-client-B", OwnerTypeV3.Maintenance, ttlSeconds: 30);
            using var driverB = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
            await RetryAsync(() => driverB.ConnectAsync(CancellationToken.None));
            // 并发发送各 5 次
            var tasksA = Enumerable.Range(0, 5).Select(i =>
                driverA.SendCommandAsync($"SEND:0x{i:X3}:AA", CancellationToken.None)).ToArray();
            var tasksB = Enumerable.Range(0, 5).Select(i =>
                driverB.SendCommandAsync($"SEND:0x{i + 0x10:X3}:BB", CancellationToken.None)).ToArray();
            var results = await Task.WhenAll(tasksA.Concat(tasksB));
            Assert.All(results, r => Assert.Contains("OK:TX", r));
            // 两个租约行均保持 Active
            Assert.Equal(LeasePhase.Active, _clientLease.PhaseOf(DevId));
            _clientLease.Release(grantA, "test-end-A");
        }
        /// <summary>
        /// 两个 Production 客户端竞争同一设备租约:第一个成功获取,第二个应得到 ConflictHeld。
        /// </summary>
        [Fact]
        public void TwoProductionClients_SecondGetsConflictHeld()
        {
            var grantA = GrabLease();
            Assert.Equal(LeasePhase.Active, _clientLease.PhaseOf(DevId));
            // 第二个客户端(不同 OwnerId,同 OwnerType=Production)
            var secondLease = new DbLeaseStore(_arbDb, "second-client");
            var outcome = secondLease.Acquire(new AcquireRequest
            {
                OwnerId = "competing-client",
                OwnerType = OwnerTypeV3.Production,
                ResourceKeys = new[] { DevId },
                TtlSeconds = 30,
                WaitTimeoutMs = 0
            });
            Assert.Equal(AcquireResultCode.ConflictHeld, outcome.Code);
            Assert.NotNull(outcome.Holder);
            _clientLease.Release(grantA, "test-end");
        }
        /// <summary>
        /// 完整生命周期:Acquire → Connect → Send → Release → 再 Acquire → 再 Send。
        /// 验证租约释放后资源回到 Free,可被重新获取并继续使用。
        /// </summary>
        [Fact]
        public async Task LeaseLifecycle_AcquireSendReleaseAcquireSendAgain_AllSucceed()
        {
            var hash = DeviceConfigHash.Compute(_dataDb);
            // 第一轮:获取租约 → 连接 → 发送 → 释放
            LeaseGrant grant1;
            {
                var outcome = _clientLease.Acquire(new AcquireRequest
                {
                    OwnerId = "lifecycle-1",
                    OwnerType = OwnerTypeV3.Production,
                    ResourceKeys = new[] { DevId },
                    TtlSeconds = 30
                });
                Assert.Equal(AcquireResultCode.Acquired, outcome.Code);
                grant1 = outcome.Grants[0];
            }
            using (var driver1 = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash))
            {
                await RetryAsync(() => driver1.ConnectAsync(CancellationToken.None));
                var tx = await driver1.SendCommandAsync("SEND:0x100:AA", CancellationToken.None);
                Assert.Contains("OK:TX", tx);
            }
            Assert.True(_clientLease.Release(grant1, "lifecycle-1-end"));
            Assert.Equal(LeasePhase.Free, _clientLease.PhaseOf(DevId));
            // 第二轮:重新获取 → 连接 → 发送
            LeaseGrant grant2;
            {
                var outcome = _clientLease.Acquire(new AcquireRequest
                {
                    OwnerId = "lifecycle-2",
                    OwnerType = OwnerTypeV3.Production,
                    ResourceKeys = new[] { DevId },
                    TtlSeconds = 30
                });
                Assert.Equal(AcquireResultCode.Acquired, outcome.Code);
                grant2 = outcome.Grants[0];
            }
            using (var driver2 = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash))
            {
                await RetryAsync(() => driver2.ConnectAsync(CancellationToken.None));
                var tx = await driver2.SendCommandAsync("SEND:0x200:BB", CancellationToken.None);
                Assert.Contains("OK:TX", tx);
            }
            _clientLease.Release(grant2, "lifecycle-2-end");
        }
        /// <summary>
        /// 并发压力:单客户端 20 个 Task 同时发送,验证 SessionGate + 管道写入锁不崩溃。
        /// </summary>
        [Fact]
        public async Task SingleClient_ConcurrentSend20Tasks_AllSucceed()
        {
            var grant = GrabLease();
            var hash = DeviceConfigHash.Compute(_dataDb);
            using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
            await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
            var tasks = Enumerable.Range(0, 20).Select(i =>
                driver.SendCommandAsync($"SEND:0x{i:X3}:DD", CancellationToken.None)).ToArray();
            var results = await Task.WhenAll(tasks);
            Assert.All(results, r => Assert.Contains("OK:TX", r));
            Assert.Equal(LeasePhase.Active, _clientLease.PhaseOf(DevId));
            _clientLease.Release(grant, "test-end");
        }
        // ==================== 辅助 ====================
        /// <summary>绕过 Acquire 互斥,直接向 arb.db 插入一条 Active 租约行(测试并发场景用)。</summary>
        private void InsertDirectLeaseRow(string deviceId, string ownerId, OwnerTypeV3 ownerType, int ttlSeconds)
        {
            long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
            using var conn = new System.Data.SQLite.SQLiteConnection(
                $"Data Source={_arbDb};Version=3;");
            conn.Open();
            using var cmd = conn.CreateCommand();
            cmd.CommandText = @"
                INSERT OR REPLACE INTO resource_state
                    (ResourceKey, Generation, Phase, LeaseId, OwnerId, OwnerType,
                     ProcessInstanceId, MachineName, HostProcessId, Epoch, Reason,
                     AcquiredUtcMs, ExpiresUtcMs, UpdatedUtcMs)
                VALUES (@key, 1, 1, @lid, @oid, @otype, '', 'local', 0, 1, 'direct-insert',
                        @now, @exp, @now)";
            cmd.Parameters.AddWithValue("@key", DbLeaseStore.NormKey(deviceId));
            cmd.Parameters.AddWithValue("@lid", "lease_" + Guid.NewGuid().ToString("N").Substring(0, 12));
            cmd.Parameters.AddWithValue("@oid", ownerId);
            cmd.Parameters.AddWithValue("@otype", (int)ownerType);
            cmd.Parameters.AddWithValue("@now", now);
            cmd.Parameters.AddWithValue("@exp", now + ttlSeconds * 1000L);
            cmd.ExecuteNonQuery();
        }
        private sealed class CanFrameish { public string Id; public string Data; public string Src; }
        [Fact]