From 32ba1136c71380daedb886fe642d62ccd5ed5900 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 25 Jun 2026 15:42:20 +0300 Subject: [PATCH] =?UTF-8?q?test(v0.4):=20integration=20=E2=80=94=20thermal?= =?UTF-8?q?-trip/abort=20+=20MCU=20fail-safe-cut=20(session-=D1=88=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit + фикс B09-таймера: last_heartbeat Option вместо сентинела 0 (monotonic_secs() стартует с 0 → первый heartbeat попадал на 0, guard !=0 ложно трактовал как «не было» → cut не срабатывал). Co-Authored-By: Claude Opus 4.8 Signed-off-by: Alexander --- crates/core/shturman-power/src/coprocessor.rs | 12 +-- .../core/shturman-power/tests/integration.rs | 98 +++++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/crates/core/shturman-power/src/coprocessor.rs b/crates/core/shturman-power/src/coprocessor.rs index 43e6919..6b6faca 100644 --- a/crates/core/shturman-power/src/coprocessor.rs +++ b/crates/core/shturman-power/src/coprocessor.rs @@ -65,7 +65,7 @@ struct MockState { soc_decoder: FrameDecoder, mcu_seq: u8, out: Vec, - last_heartbeat: u64, + last_heartbeat: Option, // None = heartbeat ещё не было (sentinel 0 коллизировал с monotonic-стартом) shutdown_at: Option, safe_to_cut: bool, hung: bool, @@ -105,7 +105,7 @@ impl Coprocessor for MockCoprocessor { match msg { SocToMcu::Heartbeat => { if !s.hung { - s.last_heartbeat = now; + s.last_heartbeat = Some(now); } } SocToMcu::ShutdownImminent { .. } => s.shutdown_at = Some(now), @@ -121,10 +121,10 @@ impl Coprocessor for MockCoprocessor { let s = self.st.lock().unwrap(); match s.shutdown_at { // running: тишина heartbeat дольше FAILSAFE_MISS окон → SoC завис → cut - None => { - s.last_heartbeat != 0 - && s.now.saturating_sub(s.last_heartbeat) > FAILSAFE_MISS * HEARTBEAT_SECS - } + // None last_heartbeat = heartbeat ещё не было → не режем (startup, не зависание) + None => s + .last_heartbeat + .is_some_and(|h| s.now.saturating_sub(h) > FAILSAFE_MISS * HEARTBEAT_SECS), // shutdown: бюджет истёк без safe-to-cut → cut Some(t0) => !s.safe_to_cut && s.now.saturating_sub(t0) > HOLDUP_BUDGET_SECS, } diff --git a/crates/core/shturman-power/tests/integration.rs b/crates/core/shturman-power/tests/integration.rs index d0c806e..d1bf512 100644 --- a/crates/core/shturman-power/tests/integration.rs +++ b/crates/core/shturman-power/tests/integration.rs @@ -104,3 +104,101 @@ async fn shutdown_imminent_then_abort() { aborted.next().await.unwrap(); assert_eq!(power.power_state().await.unwrap(), PowerState::Running); } + +#[tokio::test] +#[ignore = "нужна session-шина: just test-integration"] +async fn thermal_trip_then_clear() { + let svc = PowerService::new(); + let fsm = svc.fsm_handle(); + let thermal_state = svc.thermal_state_handle(); + let temp = Arc::new(MockTempSource::new(20)); + let copro = Arc::new(MockCoprocessor::new()); + + let server = zbus::Connection::session().await.unwrap(); + server + .object_server() + .at(names::power::PATH, svc) + .await + .unwrap(); + server.request_name(names::power::NAME).await.unwrap(); + let iface = server + .object_server() + .interface::<_, PowerService>(names::power::PATH) + .await + .unwrap(); + let ctx = iface.signal_context().to_owned(); + shturman_power::service::spawn_loops( + fsm, + thermal_state, + temp.clone() as Arc, + Arc::new(shturman_power::thermal::NoopThrottler::default()) + as Arc, + copro as Arc, + ctx, + ); + + let client = zbus::Connection::session().await.unwrap(); + let power = PowerClient::new(&client).await.unwrap(); + let mut imminent = power.proxy().receive_shutdown_imminent().await.unwrap(); + let mut aborted = power.proxy().receive_shutdown_aborted().await.unwrap(); + + // перегрев → ShutdownImminent(thermal) + temp.set(99); + let sig = imminent.next().await.unwrap(); + assert_eq!(sig.args().unwrap().reason(), "thermal"); + assert_eq!(power.power_state().await.unwrap(), PowerState::ShuttingDown); + + // остыло до PONR → ShutdownAborted + running + temp.set(20); + aborted.next().await.unwrap(); + assert_eq!(power.power_state().await.unwrap(), PowerState::Running); +} + +#[tokio::test] +#[ignore = "нужна session-шина: just test-integration"] +async fn mcu_failsafe_cuts_on_hang() { + let svc = PowerService::new(); + let fsm = svc.fsm_handle(); + let thermal_state = svc.thermal_state_handle(); + let temp = Arc::new(MockTempSource::new(20)); + let copro = Arc::new(MockCoprocessor::new()); + + let server = zbus::Connection::session().await.unwrap(); + server + .object_server() + .at(names::power::PATH, svc) + .await + .unwrap(); + server.request_name(names::power::NAME).await.unwrap(); + let iface = server + .object_server() + .interface::<_, PowerService>(names::power::PATH) + .await + .unwrap(); + let ctx = iface.signal_context().to_owned(); + shturman_power::service::spawn_loops( + fsm, + thermal_state, + temp as Arc, + Arc::new(shturman_power::thermal::NoopThrottler::default()) + as Arc, + copro.clone() as Arc, + ctx, + ); + + let client = zbus::Connection::session().await.unwrap(); + let power = PowerClient::new(&client).await.unwrap(); + assert_eq!(power.power_state().await.unwrap(), PowerState::Running); + + // дать coproc-циклу послать ≥1 heartbeat (иначе last_heartbeat=0 и guard не даст cut) + tokio::time::sleep(std::time::Duration::from_millis(1300)).await; + copro.hang(); // SoC завис → heartbeat не освежает таймер + // ждём, пока coproc-цикл (HEARTBEAT=1с) накопит > FAILSAFE_MISS окон и сделает FailsafeCut + for _ in 0..10 { + tokio::time::sleep(std::time::Duration::from_millis(700)).await; + if power.power_state().await.unwrap() == PowerState::Off { + break; + } + } + assert_eq!(power.power_state().await.unwrap(), PowerState::Off); +}