| | |
| | | using System; |
| | | using System.Diagnostics; |
| | | using System.IO; |
| | | using System.Linq; |
| | | using System.Threading; |
| | | using System.Threading.Tasks; |
| | | using Aging.Infrastructure.Broker; |
| | |
| | | _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] |