Bläddra i källkod

fix(d2-02-t3): 会话释放/超时回收前先停标定协作(StopAndWait)消除标定线程与采集争用lease(Critical)+operate退出兜底StopAsync

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 1 dag sedan
förälder
incheckning
5d1e042866

+ 63 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/CalibrationCoordinatorTests.cs

@@ -176,6 +176,69 @@ namespace IvfTl.ControlHost.Tests
             Assert.True(SpinUntil(() => !co.GetProgress().IsRunning, 2000));
         }
 
+        // 8. StopAndWait:起标定(后台,慢孔模拟)→ StopAndWait 返回后 IsRunning=false、当前孔已跑完、剩余孔仍 Pending。
+        // 这是 Critical 并发修复的核心:会话释放前必须先停标定并等当前孔跑完,之后 lease 才能 Dispose、采集才恢复。
+        [Fact]
+        public void StopAndWait_BlocksUntilCurrentWellDone_RestPending()
+        {
+            var wells = new List<int> { 1, 2, 3, 4 };
+            var entered = new ManualResetEventSlim(false);
+            var release = new ManualResetEventSlim(false);
+            int calls = 0;
+
+            var co = New(w =>
+            {
+                Interlocked.Increment(ref calls);
+                entered.Set();
+                release.Wait(2000);   // 模拟慢孔(移电机+抓帧)
+                Thread.Sleep(50);
+                return Good(w);
+            });
+
+            co.Start(wells); // 后台线程异步
+            Assert.True(entered.Wait(2000), "第1孔应进入 runner");
+
+            // 放行慢孔,同时调 StopAndWait —— 必须阻塞到后台线程结束、IsRunning=false 才返回 true。
+            release.Set();
+            bool ok = co.StopAndWait(2000);
+
+            Assert.True(ok, "StopAndWait 应在超时内成功等到后台线程结束");
+            var p = co.GetProgress();
+            Assert.False(p.IsRunning, "StopAndWait 返回后必须 IsRunning=false(当前孔已跑完、不再起下一孔)");
+            Assert.Equal(1, calls);   // 只跑了第1孔
+            Assert.Equal(WellCalibState.Qualified, p.Wells.Single(x => x.WellSn == 1).State);
+            Assert.All(p.Wells.Where(x => x.WellSn != 1),
+                       w => Assert.Equal(WellCalibState.Pending, w.State));
+        }
+
+        // 9. StopAndWait 在未起标定(无后台线程)时直接返回 true(no-op,IsRunning 本就 false)。
+        [Fact]
+        public void StopAndWait_WhenIdle_ReturnsTrueImmediately()
+        {
+            var co = New(Good);
+            Assert.True(co.StopAndWait(1000));
+            Assert.False(co.GetProgress().IsRunning);
+        }
+
+        // 10. StopAndWait 超时(当前孔卡死不返回)→ 返回 false,不无限阻塞调用方(会话释放兜底)。
+        [Fact]
+        public void StopAndWait_Timeout_ReturnsFalse()
+        {
+            var entered = new ManualResetEventSlim(false);
+            var stuck = new ManualResetEventSlim(false);
+            var co = New(w =>
+            {
+                entered.Set();
+                stuck.Wait(5000);   // 卡住不返回
+                return Good(w);
+            });
+            co.Start(new List<int> { 1, 2 });
+            Assert.True(entered.Wait(2000));
+            bool ok = co.StopAndWait(300);   // 短超时
+            Assert.False(ok, "当前孔未在超时内跑完 → StopAndWait 返回 false");
+            stuck.Set();   // 收尾,避免线程泄漏
+        }
+
         static bool SpinUntil(Func<bool> cond, int timeoutMs)
         {
             var sw = System.Diagnostics.Stopwatch.StartNew();

+ 10 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/CalibrationManagerTests.cs

@@ -63,6 +63,16 @@ namespace IvfTl.ControlHost.Tests
             Assert.True(calib.Stop(null).Ok);
         }
 
+        // Critical 并发修复:StopAndWait 对未起标定的 sid 是 no-op 且返回 true,
+        // 让 DebugSessionManager 的"会话即将关闭"回调即便该会话没在标定也能畅通(不阻塞 Dispose)。
+        [Fact]
+        public void StopAndWait_Unknown_Sid_Is_NoOp_ReturnsTrue()
+        {
+            var (calib, _, _) = New();
+            Assert.True(calib.StopAndWait("nope", 1000));
+            Assert.True(calib.StopAndWait(null, 1000));
+        }
+
         // ── Task3.2b:标定预览帧缓冲的「非标定/无帧」安全分支(推流线程据此决定走 GrabStable) ──
 
         [Fact]

+ 45 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugSessionManagerTests.cs

@@ -68,5 +68,50 @@ namespace IvfTl.ControlHost.Tests
             Assert.Equal(0, mgr.SweepExpired());
             Assert.False(gate.LastLease.Disposed);
         }
+
+        // ── Critical 并发修复:会话关闭(Release/Sweep)在 lease.Dispose 之前先触发 onSessionClosing 回调 ──
+        // 装配时回调接到 calibMgr.StopAndWait(sid),先停标定并等当前孔跑完,之后才 Dispose lease、恢复采集,
+        // 消除"标定后台线程操作已 Dispose 的 lease + 与已恢复的采集线程争同一硬件"的窗口。
+        // 验证两点:① 回调被调且传入正确 sid;② 回调发生在 lease.Dispose 之前(用 FakeLease.Disposed 在回调里取快照)。
+
+        private (DebugSessionManager mgr, FakeGate gate, System.Collections.Generic.List<(string sid, bool disposedAtCallback)> log)
+            NewWithClosing()
+        {
+            var serial = new FakeSerial();
+            var gate = new FakeGate(5, serial) { CanAcquire = true };
+            var log = new System.Collections.Generic.List<(string, bool)>();
+            // 回调里读 LastLease.Disposed:若回调真在 Dispose 之前调,这里应为 false。
+            var mgr = new DebugSessionManager(sn => gate, () => _now, ttlMs: 10000, log: _ => { },
+                onSessionClosing: sid => log.Add((sid, gate.LastLease.Disposed)));
+            return (mgr, gate, log);
+        }
+
+        [Fact]
+        public void Release_Invokes_OnSessionClosing_Before_Dispose()
+        {
+            var (mgr, gate, log) = NewWithClosing();
+            string sid = (string)mgr.Acquire(5).Result;
+            mgr.Release(sid);
+
+            Assert.Single(log);
+            Assert.Equal(sid, log[0].sid);
+            Assert.False(log[0].disposedAtCallback);   // 回调时 lease 尚未 Dispose
+            Assert.True(gate.LastLease.Disposed);       // 之后已 Dispose
+        }
+
+        [Fact]
+        public void Sweep_Invokes_OnSessionClosing_Before_Dispose()
+        {
+            var (mgr, gate, log) = NewWithClosing();
+            string sid = (string)mgr.Acquire(5).Result;
+            _now = _now.AddMilliseconds(11000);
+            int n = mgr.SweepExpired();
+
+            Assert.Equal(1, n);
+            Assert.Single(log);
+            Assert.Equal(sid, log[0].sid);
+            Assert.False(log[0].disposedAtCallback);   // 超时回收路径同样:回调先于 Dispose
+            Assert.True(gate.LastLease.Disposed);
+        }
     }
 }

+ 19 - 0
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/CalibrationCoordinator.cs

@@ -99,6 +99,25 @@ namespace IvfTl.ControlHost.Debug
             _log("[calib] 收到中止请求");
         }
 
+        /// <summary>
+        /// 停标定并【阻塞等当前孔跑完、后台线程结束】(Critical 并发修复):
+        /// 置 _stop(不再起下一孔) → Join 后台线程(等当前孔 CalibrateWell 自然跑完)。
+        /// 单孔内 CalibrateWell 不可中断(可接受),但返回后保证后台线程已退出、IsRunning=false,
+        /// 调用方(会话释放)随后才能安全 Dispose lease、恢复采集,消除标定线程与采集线程争用同一硬件的窗口。
+        /// 超时(当前孔卡死未在 timeoutMs 内跑完)返回 false,不无限阻塞调用方;未起标定(无后台线程)直接返回 true。
+        /// </summary>
+        public bool StopAndWait(int timeoutMs)
+        {
+            _stop = true;
+            _log("[calib] 收到停并等请求,等当前孔跑完...");
+            // 取后台线程引用(Start 设);可能正巧已收尾,故快照后判活。
+            var worker = _worker;
+            if (worker == null || !worker.IsAlive) return true;
+            bool joined = worker.Join(timeoutMs);
+            if (!joined) _log($"[calib] StopAndWait 超时({timeoutMs}ms),当前孔未跑完");
+            return joined;
+        }
+
         /// <summary>
         /// 单孔重标:把该孔置 Pending 再跑一次单孔内核(与逐孔编排共用 CalibrateOneWell)。
         /// 已在批量跑则忽略返回 false。synchronous 同 Start。

+ 15 - 0
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/CalibrationManager.cs

@@ -178,5 +178,20 @@ namespace IvfTl.ControlHost.Debug
             }
             return DebugCommandResult.Okay();
         }
+
+        /// <summary>
+        /// (Critical 并发修复)停该 sid 标定并【等当前孔跑完】后再返回,供 DebugSessionManager 在
+        /// 会话释放(Release/SweepExpired)Dispose lease 之前调用:确保标定后台线程已退出、不再操作即将 Dispose 的 lease,
+        /// 之后 lease 才 Dispose、采集才恢复,消除标定线程与采集线程争用同一硬件的窗口。
+        /// 无对应 sid(该会话没在标定)→ no-op 返回 true,不阻塞 Dispose。超时返回 false(协作器内当前孔卡死)。
+        /// </summary>
+        public bool StopAndWait(string sid, int timeoutMs)
+        {
+            if (sid == null || !_bySid.TryRemove(sid, out var c)) return true;
+            bool ok = c.StopAndWait(timeoutMs);
+            _lastFrameBySid.TryRemove(sid, out _);   // 清该 sid 帧缓冲(同 Stop)
+            _log($"[calib] 停并等标定 sid={sid} ok={ok}");
+            return ok;
+        }
     }
 }

+ 21 - 1
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs

@@ -15,12 +15,30 @@ namespace IvfTl.ControlHost.Debug
         private readonly int _ttlMs;
         private readonly Action<string> _log;
         private readonly Func<int, (bool cultivating, int embryoCount)> _cultivationOf;
+        // (Critical 并发修复)"会话即将关闭"回调(参数=sid):在 Release/SweepExpired 回收某会话、
+        // Dispose lease 之前调用,通知标定先停并等当前孔跑完(Program.cs 装配时接到 calibMgr.StopAndWait)。
+        // 用可空 Action + 可后置 SetOnSessionClosing,打破"CalibrationManager 依赖 DebugSessionManager / 回调又要调
+        // CalibrationManager"的循环依赖(先 new debugMgr → new calibMgr(debugMgr) → debugMgr.SetOnSessionClosing(...))。
+        private volatile Action<string> _onSessionClosing;
         private readonly ConcurrentDictionary<string, DebugSession> _sessions = new ConcurrentDictionary<string, DebugSession>();
         public DebugSessionManager(Func<int, IHouseGate> gateOf, Func<DateTime> clock, int ttlMs, Action<string> log,
-                                   Func<int, (bool, int)> cultivationOf = null)
+                                   Func<int, (bool, int)> cultivationOf = null, Action<string> onSessionClosing = null)
         {
             _gateOf = gateOf; _clock = clock; _ttlMs = ttlMs; _log = log ?? (_ => { });
             _cultivationOf = cultivationOf;
+            _onSessionClosing = onSessionClosing;
+        }
+
+        /// <summary>(装配用)后置注入"会话即将关闭"回调,打破与 CalibrationManager 的循环依赖。</summary>
+        public void SetOnSessionClosing(Action<string> onSessionClosing) => _onSessionClosing = onSessionClosing;
+
+        // 会话关闭前钩子:Dispose lease 之前调,先停该 sid 标定并等当前孔跑完(回调内部吞异常,绝不阻断回收/Dispose)。
+        private void InvokeOnSessionClosing(string sid)
+        {
+            var cb = _onSessionClosing;
+            if (cb == null) return;
+            try { cb(sid); }
+            catch (Exception ex) { _log($"[debug] onSessionClosing 回调异常 sid={sid}: {ex.Message}"); }
         }
         public DebugCommandResult Acquire(int houseSn)
         {
@@ -54,6 +72,7 @@ namespace IvfTl.ControlHost.Debug
         {
             if (sid != null && _sessions.TryRemove(sid, out var s))
             {
+                InvokeOnSessionClosing(sid);   // (Critical)Dispose 前先停标定并等当前孔跑完,再恢复采集,消除争用
                 try { s.Lease.Dispose(); } catch (Exception ex) { _log($"[debug] dispose 异常 sid={sid} 舱{s.HouseSn}: {ex.Message}"); }
                 _log($"[debug] release sid={sid} 舱{s.HouseSn}");
             }
@@ -69,6 +88,7 @@ namespace IvfTl.ControlHost.Debug
                 {
                     if (_sessions.TryRemove(kv.Key, out var s))
                     {
+                        InvokeOnSessionClosing(kv.Key);   // (Critical)同 Release:超时回收路径也在 Dispose 前先停标定并等当前孔跑完
                         try { s.Lease.Dispose(); } catch (Exception ex) { _log($"[debug] dispose 异常 sid={kv.Key} 舱{s.HouseSn}: {ex.Message}"); }
                         _log($"[debug] 会话超时自动回收 sid={kv.Key} 舱{s.HouseSn}");
                         n++;

+ 4 - 0
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Program.cs

@@ -57,6 +57,10 @@ namespace IvfTl.ControlHost
                     debugMgr,
                     houseSn => { try { return ivf_tl_Control.AppData.Instance.GetHouseBin(houseSn); } catch { return null; } },
                     msg => Log4netHelper.WriteLog(msg));
+                // (Critical 并发修复)后置接回调,打破循环依赖:debugMgr 已 new、calibMgr 依赖 debugMgr 已 new,
+                // 现把"会话即将关闭"钩子接到 calibMgr.StopAndWait —— Release/超时回收在 Dispose lease 前先停标定并等当前孔跑完,
+                // 之后才恢复采集,消除标定后台线程与采集线程争用同一硬件的窗口。超时 30s(单孔标定上限,够跑完当前孔)。
+                debugMgr.SetOnSessionClosing(sid => { try { calibMgr.StopAndWait(sid, 30000); } catch { } });
                 _http = new ControlHttpServer(
                     hostArgs.Port,
                     BuildStatus,        // /ping 轻量

+ 8 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/ViewModel/HouseDebugPageViewModel.cs

@@ -390,6 +390,14 @@ namespace ivf_tl_Operate.ViewModel
             try
             {
                 // D2-02-T3.4:先停标定轮询、归还 control 协作会话(停心跳),再归还本地 HAL 借用。
+                // (Critical 并发兜底)正常退出时先主动通知 control 停标定(bounded 等一下让停标请求到达),
+                // 让 control 端尽早停 coordinator —— control 端 onSessionClosing→StopAndWait 已兜底超时回收路径,
+                // 这里是双保险,使正常退出更快停标、缩短"标定后台线程仍在跑"的窗口。失败不阻断卸载。
+                var calib = _calibClient;
+                if (calib != null)
+                {
+                    try { calib.StopAsync().Wait(3000); } catch (Exception ex) { ExLog(ex, "ComHouseUnit.StopAsync"); }
+                }
                 StopCalibPolling();
                 ReleaseDebugSession();