From a176aedef81dd5f31dff5ea29cc39519384bb580 Mon Sep 17 00:00:00 2001 From: Cheng Sin Pang Date: Wed, 13 May 2026 07:23:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(integration):=20SystemController=20?= =?UTF-8?q?=E5=9C=A8=20resolved=3DNone=20=E6=99=82=20resume=20heartbeat=20?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=8D=A1=20paused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_on_strategy_change` 原本在 `resolved is not None` 條件下才呼叫 pause/resume, 當前一個策略 suppress_heartbeat=True(如 BypassStrategy / CascadingStrategy)切換 到 resolved=None(remove_base_mode / deactivate_schedule_mode 後無 override), heartbeat 會永久卡在 paused,設備端會因超時進入安全模式。 修復:resolved is None 時走 resume 分支(idempotent,無策略 assert suppress 時 安全預設為持續送心跳)。 加上兩個 regression test: - BypassStrategy → remove_base_mode 後 heartbeat.is_paused 必須為 False - 非 suppress 策略 → resolved=None 不應誤觸 pause --- csp_lib/integration/system_controller.py | 6 ++- tests/integration/test_system_controller.py | 59 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/csp_lib/integration/system_controller.py b/csp_lib/integration/system_controller.py index 1b112b7..d69b180 100644 --- a/csp_lib/integration/system_controller.py +++ b/csp_lib/integration/system_controller.py @@ -1292,8 +1292,10 @@ async def _on_strategy_change(self, old: Strategy | None, new: Strategy | None) await self._executor.set_strategy(resolved) # 依據策略的 suppress_heartbeat 控制心跳服務 - if self._heartbeat is not None and resolved is not None: - if resolved.suppress_heartbeat: + # resolved=None(無策略 assert suppress)時必須 resume,否則 suppress_heartbeat=True + # 策略移除後 heartbeat 會卡在 paused,導致設備超時進入安全模式 + if self._heartbeat is not None: + if resolved is not None and resolved.suppress_heartbeat: self._heartbeat.pause() else: self._heartbeat.resume() diff --git a/tests/integration/test_system_controller.py b/tests/integration/test_system_controller.py index 0186552..865cdc7 100644 --- a/tests/integration/test_system_controller.py +++ b/tests/integration/test_system_controller.py @@ -545,6 +545,65 @@ async def test_remove_base_mode_reverts(self): assert sc.executor.current_strategy is s1 +class TestHeartbeatPauseResumeOnStrategyChange: + """Regression: `_on_strategy_change` 在 resolved=None 時必須 resume heartbeat。 + + Bug: 當前一個策略 suppress_heartbeat=True(如 BypassStrategy)切換到 resolved=None + (所有 base mode 被移除、無 override 活著),heartbeat 會永遠卡在 paused, + 導致現場設備超時進入安全模式 — 即使根本沒有策略在 assert suppress。 + """ + + def _build_controller_with_heartbeat(self) -> SystemController: + from csp_lib.integration.heartbeat import HeartbeatService + from csp_lib.integration.schema import HeartbeatMapping + + reg = DeviceRegistry() + # 直接注入 HeartbeatService(不啟動,僅測試 pause/resume 路徑) + sc = SystemController(reg, SystemControllerConfig()) + sc._heartbeat = HeartbeatService( + reg, + mappings=[HeartbeatMapping(point_name="heartbeat", trait="pcs")], + interval=1.0, + ) + return sc + + @pytest.mark.asyncio + async def test_resolved_none_after_suppress_strategy_resumes_heartbeat(self): + """suppress_heartbeat=True 策略 → resolved=None 後 heartbeat 必須 resume,不可永久卡 paused。""" + from csp_lib.controller.strategies import BypassStrategy + + sc = self._build_controller_with_heartbeat() + bypass = BypassStrategy() + sc.register_mode("bypass", bypass, ModePriority.SCHEDULE) + + # 啟用 bypass → heartbeat 應 pause + await sc.set_base_mode("bypass") + assert sc._heartbeat is not None + assert sc._heartbeat.is_paused is True, "BypassStrategy 啟用後 heartbeat 應為 paused" + + # 移除 base mode → resolved 變 None + await sc.remove_base_mode("bypass") + + # Bug: heartbeat 仍然卡在 paused(修復後應為 False) + assert sc._heartbeat.is_paused is False, ( + "resolved=None 後 heartbeat 不應仍為 paused — 否則設備永遠收不到心跳會進入安全模式" + ) + + @pytest.mark.asyncio + async def test_resolved_none_without_prior_suppress_keeps_heartbeat_unpaused(self): + """非 suppress 策略 → resolved=None:heartbeat 不應被誤觸 pause。""" + sc = self._build_controller_with_heartbeat() + s = MockStrategy() + sc.register_mode("pq", s, ModePriority.SCHEDULE) + + await sc.set_base_mode("pq") + assert sc._heartbeat is not None + assert sc._heartbeat.is_paused is False + + await sc.remove_base_mode("pq") + assert sc._heartbeat.is_paused is False + + class TestSystemControllerLifecycle: @pytest.mark.asyncio async def test_start_stop(self):