Skip to main content

robot_bus/tank/
mod.rs

1//! In-process differential-drive tank simulation (`tank`).
2//!
3//! Subscribes [`CMD_VEL_TOPIC`], integrates pose on an 11×11 world, and publishes
4//! [`POSE_TOPIC`] at 20 Hz. Also serves:
5//! - action [`POINT_NAV_ACTION`] — drive to one planar pose
6//! - action [`MULTI_WAYPOINT_NAV_ACTION`] — visit poses in order
7//! - service [`RESET_SERVICE`] — snap pose back to world center (home / 原点)
8//!
9//! Intended to run as a managed singleton beside the broker (console sessions
10//! acquire/release it); multiple viewers share one world and `cmd_vel` is
11//! last-writer-wins (ignored while an action is navigating).
12
13use std::collections::HashMap;
14use std::f64::consts::PI;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::{Arc, Mutex, Weak};
17use std::thread::{self, JoinHandle};
18use std::time::{Duration, Instant};
19
20use uuid::Uuid;
21
22use crate::action::v1::{
23    MultiWaypointNavigation, MultiWaypointNavigationFeedback, MultiWaypointNavigationGoal,
24    MultiWaypointNavigationResult, PointNavigation, PointNavigationFeedback, PointNavigationGoal,
25    PointNavigationResult,
26};
27use crate::geometry_msgs::msg::v1::{Pose2D, Twist};
28use crate::robot_bus_interfaces::srv::v1::{Reset, ResetRequest, ResetResponse};
29use crate::runtime::{CallbackGroupType, Context, MultiThreadedExecutor, Node, NodeOptions};
30use crate::{ActionOutcome, BusError, Result};
31
32/// Built-in tank demo namespace under the reserved `/robot_bus/*` prefix.
33pub const TANK_PREFIX: &str = "/robot_bus/tank";
34pub const CMD_VEL_TOPIC: &str = "/robot_bus/tank/cmd_vel";
35pub const POSE_TOPIC: &str = "/robot_bus/tank/pose";
36pub const POINT_NAV_ACTION: &str = "/robot_bus/tank/point_navigation";
37pub const MULTI_WAYPOINT_NAV_ACTION: &str = "/robot_bus/tank/multi_waypoint_navigation";
38pub const RESET_SERVICE: &str = "/robot_bus/tank/reset";
39pub const WORLD_SIZE: f64 = 11.0;
40
41const TICK: Duration = Duration::from_millis(50);
42/// Deadman only: if cmd_vel stops arriving (client crash / drop), coast this long then halt.
43/// Normal teleop publishes an explicit zero on key-up — do not rely on this for stop feel.
44const CMD_TIMEOUT: Duration = Duration::from_millis(100);
45const NAV_LINEAR: f64 = 1.5;
46const NAV_ANGULAR: f64 = 2.2;
47const POS_TOL: f64 = 0.08;
48const YAW_TOL: f64 = 0.08;
49/// Session lease — frontend should heartbeat more often than this.
50pub const DEFAULT_LEASE: Duration = Duration::from_secs(15);
51/// Delay before stopping the sim after sessions expire without an explicit release
52/// (tab crash / missed DELETE). Explicit last-viewer close stops immediately.
53pub const DEFAULT_STOP_GRACE: Duration = Duration::from_secs(2);
54
55/// Connect endpoints for the message / service / action buses (client-side).
56#[derive(Clone, Debug)]
57pub struct TankEndpoints {
58    pub message_xsub: String,
59    pub message_xpub: String,
60    pub service_frontend: String,
61    pub service_backend: String,
62    pub action_backend: String,
63}
64
65struct SimState {
66    x: f64,
67    y: f64,
68    theta: f64,
69    linear: f64,
70    angular: f64,
71    last_cmd: Instant,
72    /// True while an action owns the pose (teleop cmd_vel ignored).
73    navigating: bool,
74    /// Bumped by reset / newer goals so in-flight nav aborts.
75    abort_token: u64,
76}
77
78impl Default for SimState {
79    fn default() -> Self {
80        Self {
81            x: WORLD_SIZE / 2.0,
82            y: WORLD_SIZE / 2.0,
83            theta: 0.0,
84            linear: 0.0,
85            angular: 0.0,
86            last_cmd: Instant::now(),
87            navigating: false,
88            abort_token: 0,
89        }
90    }
91}
92
93impl SimState {
94    fn pose(&self) -> Pose2D {
95        Pose2D {
96            x: self.x,
97            y: self.y,
98            theta: self.theta,
99        }
100    }
101
102    fn snap_home(&mut self) {
103        self.abort_token = self.abort_token.wrapping_add(1);
104        self.navigating = false;
105        self.x = WORLD_SIZE / 2.0;
106        self.y = WORLD_SIZE / 2.0;
107        self.theta = 0.0;
108        self.linear = 0.0;
109        self.angular = 0.0;
110        self.last_cmd = Instant::now();
111    }
112}
113
114/// Background physics node handle.
115pub struct TankHandle {
116    stop: Arc<AtomicBool>,
117    ready: Arc<AtomicBool>,
118    join: Option<JoinHandle<()>>,
119}
120
121impl TankHandle {
122    /// Spawn `tank` on a dedicated thread (publisher stays thread-local).
123    pub fn start(endpoints: TankEndpoints) -> Result<Self> {
124        let stop = Arc::new(AtomicBool::new(false));
125        let ready = Arc::new(AtomicBool::new(false));
126        let stop_flag = Arc::clone(&stop);
127        let ready_flag = Arc::clone(&ready);
128        let join = thread::Builder::new()
129            .name("robot-bus-tank".into())
130            .spawn(move || {
131                if let Err(err) = run_loop(endpoints, stop_flag, ready_flag) {
132                    eprintln!("tank exited: {err}");
133                    log::error!("tank exited: {err}");
134                }
135            })
136            .map_err(|e| BusError::Protocol(format!("spawn tank: {e}")))?;
137        Ok(Self {
138            stop,
139            ready,
140            join: Some(join),
141        })
142    }
143
144    /// Wait until the sim loop has published at least once (or timeout).
145    pub fn wait_ready(&self, timeout: Duration) -> bool {
146        let deadline = Instant::now() + timeout;
147        while Instant::now() < deadline {
148            if self.ready.load(Ordering::Relaxed) {
149                return true;
150            }
151            thread::sleep(Duration::from_millis(20));
152        }
153        self.ready.load(Ordering::Relaxed)
154    }
155
156    pub fn request_stop(&self) {
157        self.stop.store(true, Ordering::Relaxed);
158    }
159
160    pub fn stop(mut self) {
161        self.request_stop();
162        if let Some(handle) = self.join.take() {
163            let _ = handle.join();
164        }
165    }
166}
167
168impl Drop for TankHandle {
169    fn drop(&mut self) {
170        self.stop.store(true, Ordering::Relaxed);
171        if let Some(handle) = self.join.take() {
172            let _ = handle.join();
173        }
174    }
175}
176
177fn run_loop(endpoints: TankEndpoints, stop: Arc<AtomicBool>, ready: Arc<AtomicBool>) -> Result<()> {
178    let mut opts = NodeOptions::tcp();
179    opts.message_xsub = Some(endpoints.message_xsub);
180    opts.message_xpub = Some(endpoints.message_xpub);
181    opts.service_frontend = Some(endpoints.service_frontend);
182    opts.service_backend = Some(endpoints.service_backend);
183    opts.action_backend = Some(endpoints.action_backend);
184
185    let context = Context::new();
186    // Worker pool so long-running nav actions don't block the pose tick.
187    let executor = MultiThreadedExecutor::with_context(context.clone(), 4);
188    let mut node = Node::with_context_options(&context, "tank", opts);
189    executor.add_node(&mut node)?;
190
191    let pose_pub = node.create_publisher::<Pose2D>(POSE_TOPIC)?;
192    let state = Arc::new(Mutex::new(SimState::default()));
193
194    {
195        let state = Arc::clone(&state);
196        node.create_subscription::<Twist, _>(
197            CMD_VEL_TOPIC,
198            move |twist| {
199                let mut s = state.lock().expect("tank state");
200                if s.navigating {
201                    return;
202                }
203                s.linear = twist.linear.as_ref().map(|v| v.x).unwrap_or(0.0);
204                s.angular = twist.angular.as_ref().map(|v| v.z).unwrap_or(0.0);
205                s.last_cmd = Instant::now();
206            },
207            None,
208        )?;
209    }
210
211    let rpc_group = node.create_callback_group(CallbackGroupType::Reentrant);
212
213    {
214        let state = Arc::clone(&state);
215        node.create_service::<Reset, _>(
216            RESET_SERVICE,
217            move |_req: ResetRequest| {
218                let mut s = state.lock().expect("tank state");
219                s.snap_home();
220                ResetResponse {
221                    success: true,
222                    msg: String::new(),
223                }
224            },
225            Some(&rpc_group),
226        )?;
227    }
228
229    {
230        let state = Arc::clone(&state);
231        node.create_action_server::<PointNavigation, _>(
232            POINT_NAV_ACTION,
233            move |goal: PointNavigationGoal| {
234                let Some(pose) = goal.pose else {
235                    abort_navigation(&state);
236                    return ActionOutcome {
237                        feedbacks: vec![],
238                        result: PointNavigationResult {
239                            success: false,
240                            msg: "missing pose".into(),
241                        },
242                    };
243                };
244                let (ok, msg, feedbacks) =
245                    navigate_waypoints(&state, &[(pose.x, pose.y, pose.theta)]);
246                ActionOutcome {
247                    feedbacks: feedbacks
248                        .into_iter()
249                        .map(|(current_pose, progress)| PointNavigationFeedback {
250                            current_pose: Some(current_pose),
251                            progress,
252                        })
253                        .collect(),
254                    result: PointNavigationResult { success: ok, msg },
255                }
256            },
257            Some(&rpc_group),
258        )?;
259    }
260
261    {
262        let state = Arc::clone(&state);
263        node.create_action_server::<MultiWaypointNavigation, _>(
264            MULTI_WAYPOINT_NAV_ACTION,
265            move |goal: MultiWaypointNavigationGoal| {
266                if goal.poses.is_empty() {
267                    // Empty goal is used by the console as a soft cancel.
268                    abort_navigation(&state);
269                    return ActionOutcome {
270                        feedbacks: vec![],
271                        result: MultiWaypointNavigationResult {
272                            success: false,
273                            msg: "cancelled".into(),
274                        },
275                    };
276                }
277                let waypoints: Vec<(f64, f64, f64)> =
278                    goal.poses.iter().map(|p| (p.x, p.y, p.theta)).collect();
279                let (ok, msg, feedbacks) = navigate_waypoints(&state, &waypoints);
280                ActionOutcome {
281                    feedbacks: feedbacks
282                        .into_iter()
283                        .map(|(current_pose, progress)| MultiWaypointNavigationFeedback {
284                            current_pose: Some(current_pose),
285                            progress,
286                        })
287                        .collect(),
288                    result: MultiWaypointNavigationResult { success: ok, msg },
289                }
290            },
291            Some(&rpc_group),
292        )?;
293    }
294
295    eprintln!(
296        "tank online — SUB {CMD_VEL_TOPIC} → PUB {POSE_TOPIC}; \
297         actions {POINT_NAV_ACTION}, {MULTI_WAYPOINT_NAV_ACTION}; \
298         service {RESET_SERVICE} (tick {}ms)",
299        TICK.as_millis()
300    );
301    log::info!(
302        "tank online — SUB {CMD_VEL_TOPIC} → PUB {POSE_TOPIC}; \
303         actions {POINT_NAV_ACTION}, {MULTI_WAYPOINT_NAV_ACTION}; \
304         service {RESET_SERVICE} (tick {}ms)",
305        TICK.as_millis()
306    );
307
308    let mut last_tick = Instant::now();
309    while !stop.load(Ordering::Relaxed) {
310        executor.spin_once(Some(Duration::from_millis(5)))?;
311
312        let now = Instant::now();
313        if now.duration_since(last_tick) < TICK {
314            continue;
315        }
316        let dt = (now - last_tick).as_secs_f64().min(0.05);
317        last_tick = now;
318
319        let pose = {
320            let mut s = state.lock().expect("tank state");
321            if !s.navigating {
322                if now.duration_since(s.last_cmd) > CMD_TIMEOUT {
323                    s.linear = 0.0;
324                    s.angular = 0.0;
325                }
326                s.theta += s.angular * dt;
327                s.x = (s.x + s.theta.cos() * s.linear * dt).clamp(0.0, WORLD_SIZE);
328                s.y = (s.y + s.theta.sin() * s.linear * dt).clamp(0.0, WORLD_SIZE);
329            }
330            s.pose()
331        };
332
333        if let Err(err) = pose_pub.publish(&pose) {
334            log::warn!("tank: publish {POSE_TOPIC} failed: {err}");
335        } else {
336            ready.store(true, Ordering::Relaxed);
337        }
338    }
339
340    let _ = node.shutdown();
341    log::info!("tank stopped");
342    Ok(())
343}
344
345fn abort_navigation(state: &Arc<Mutex<SimState>>) {
346    let mut s = state.lock().expect("tank state");
347    s.abort_token = s.abort_token.wrapping_add(1);
348    s.navigating = false;
349    s.linear = 0.0;
350    s.angular = 0.0;
351}
352
353/// Drive through planar waypoints; returns (success, msg, feedback samples).
354fn navigate_waypoints(
355    state: &Arc<Mutex<SimState>>,
356    waypoints: &[(f64, f64, f64)],
357) -> (bool, String, Vec<(Pose2D, f32)>) {
358    let token = {
359        let mut s = state.lock().expect("tank state");
360        s.abort_token = s.abort_token.wrapping_add(1);
361        let token = s.abort_token;
362        s.navigating = true;
363        s.linear = 0.0;
364        s.angular = 0.0;
365        token
366    };
367
368    let mut feedbacks = Vec::new();
369    let mut ok = true;
370    let mut msg = String::new();
371    let n = waypoints.len().max(1) as f32;
372
373    for (i, &(gx, gy, gtheta)) in waypoints.iter().enumerate() {
374        let gx = gx.clamp(0.0, WORLD_SIZE);
375        let gy = gy.clamp(0.0, WORLD_SIZE);
376        let match_yaw = i + 1 == waypoints.len();
377        match drive_to_pose(state, token, gx, gy, gtheta, match_yaw, |pose, local| {
378            let overall = (i as f32 + local) / n;
379            feedbacks.push((pose, overall.clamp(0.0, 1.0)));
380        }) {
381            Ok(()) => {}
382            Err(reason) => {
383                ok = false;
384                msg = reason;
385                break;
386            }
387        }
388    }
389
390    {
391        let mut s = state.lock().expect("tank state");
392        if s.abort_token == token {
393            s.navigating = false;
394            s.linear = 0.0;
395            s.angular = 0.0;
396        }
397    }
398
399    if ok {
400        feedbacks.push({
401            let s = state.lock().expect("tank state");
402            (s.pose(), 1.0)
403        });
404    }
405    (ok, msg, feedbacks)
406}
407
408fn drive_to_pose(
409    state: &Arc<Mutex<SimState>>,
410    token: u64,
411    gx: f64,
412    gy: f64,
413    gtheta: f64,
414    match_yaw: bool,
415    mut on_progress: impl FnMut(Pose2D, f32),
416) -> std::result::Result<(), String> {
417    let start = {
418        let s = state.lock().expect("tank state");
419        if s.abort_token != token {
420            return Err("aborted".into());
421        }
422        (s.x, s.y)
423    };
424    let path_len = ((gx - start.0).hypot(gy - start.1)).max(1e-3);
425
426    // 1) Face the goal, 2) drive, 3) optionally match yaw (final waypoint only).
427    let mut step: u32 = 0;
428    loop {
429        let (pose, phase_done, local_progress) = {
430            let mut s = state.lock().expect("tank state");
431            if s.abort_token != token {
432                return Err("aborted".into());
433            }
434            let dx = gx - s.x;
435            let dy = gy - s.y;
436            let dist = dx.hypot(dy);
437            let bearing = dy.atan2(dx);
438            let traveled = (1.0 - dist / path_len).clamp(0.0, 1.0) as f32;
439
440            if dist > POS_TOL {
441                let yaw_err = angle_diff(s.theta, bearing);
442                if yaw_err.abs() > YAW_TOL {
443                    let step_ang = NAV_ANGULAR * TICK.as_secs_f64();
444                    s.theta += yaw_err.signum() * step_ang.min(yaw_err.abs());
445                    (s.pose(), false, traveled * 0.85)
446                } else {
447                    let step_lin = NAV_LINEAR * TICK.as_secs_f64();
448                    let move_by = step_lin.min(dist);
449                    s.x = (s.x + s.theta.cos() * move_by).clamp(0.0, WORLD_SIZE);
450                    s.y = (s.y + s.theta.sin() * move_by).clamp(0.0, WORLD_SIZE);
451                    (s.pose(), false, traveled * 0.85)
452                }
453            } else if match_yaw {
454                let yaw_err = angle_diff(s.theta, gtheta);
455                if yaw_err.abs() > YAW_TOL {
456                    let step_ang = NAV_ANGULAR * TICK.as_secs_f64();
457                    s.theta += yaw_err.signum() * step_ang.min(yaw_err.abs());
458                    (
459                        s.pose(),
460                        false,
461                        0.85 + (1.0 - (yaw_err.abs() / PI) as f32) * 0.15,
462                    )
463                } else {
464                    s.x = gx;
465                    s.y = gy;
466                    s.theta = gtheta;
467                    (s.pose(), true, 1.0)
468                }
469            } else {
470                s.x = gx;
471                s.y = gy;
472                (s.pose(), true, 1.0)
473            }
474        };
475
476        step = step.wrapping_add(1);
477        if phase_done || step % 4 == 0 {
478            on_progress(pose, local_progress);
479        }
480        if phase_done {
481            return Ok(());
482        }
483        thread::sleep(TICK);
484    }
485}
486
487fn angle_diff(from: f64, to: f64) -> f64 {
488    let mut d = (to - from) % (2.0 * PI);
489    if d > PI {
490        d -= 2.0 * PI;
491    } else if d < -PI {
492        d += 2.0 * PI;
493    }
494    d
495}
496
497/// Result of creating a viewer/control session.
498#[derive(Clone, Debug)]
499pub struct TankSession {
500    pub session_id: String,
501    pub lease: Duration,
502    pub viewers: usize,
503}
504
505/// Snapshot for status APIs.
506#[derive(Clone, Debug)]
507pub struct TankStatus {
508    pub running: bool,
509    pub viewers: usize,
510}
511
512struct ManagerInner {
513    handle: Option<TankHandle>,
514    sessions: HashMap<String, Instant>,
515    stop_after: Option<Instant>,
516}
517
518/// Ref-counted session manager: first acquire starts sim; last explicit release
519/// stops it immediately so topology nodes (`tank` / `tank_viz`) disappear.
520pub struct TankManager {
521    endpoints: TankEndpoints,
522    lease: Duration,
523    stop_grace: Duration,
524    inner: Mutex<ManagerInner>,
525}
526
527impl TankManager {
528    pub fn new(endpoints: TankEndpoints) -> Arc<Self> {
529        Self::with_timing(endpoints, DEFAULT_LEASE, DEFAULT_STOP_GRACE)
530    }
531
532    pub fn with_timing(
533        endpoints: TankEndpoints,
534        lease: Duration,
535        stop_grace: Duration,
536    ) -> Arc<Self> {
537        let mgr = Arc::new(Self {
538            endpoints,
539            lease,
540            stop_grace,
541            inner: Mutex::new(ManagerInner {
542                handle: None,
543                sessions: HashMap::new(),
544                stop_after: None,
545            }),
546        });
547        Self::spawn_watch(Arc::downgrade(&mgr), stop_grace);
548        mgr
549    }
550
551    pub fn lease(&self) -> Duration {
552        self.lease
553    }
554
555    pub fn acquire(&self) -> Result<TankSession> {
556        Self::stop_taken(self.sweep_now());
557
558        let mut inner = self.lock_inner();
559        if inner.handle.is_none() {
560            let handle = TankHandle::start(self.endpoints.clone())?;
561            inner.handle = Some(handle);
562            inner.stop_after = None;
563        }
564
565        // Wait for first pose outside the lock so other acquires can proceed.
566        let ready_handle = inner.handle.as_ref().map(|h| Arc::clone(&h.ready));
567        let session_id = Uuid::new_v4().to_string();
568        inner.sessions.insert(session_id.clone(), Instant::now());
569        let viewers = inner.sessions.len();
570        drop(inner);
571
572        if let Some(ready) = ready_handle {
573            let deadline = Instant::now() + Duration::from_secs(2);
574            while Instant::now() < deadline {
575                if ready.load(Ordering::Relaxed) {
576                    break;
577                }
578                thread::sleep(Duration::from_millis(20));
579            }
580        }
581
582        Ok(TankSession {
583            session_id,
584            lease: self.lease,
585            viewers,
586        })
587    }
588
589    pub fn heartbeat(&self, session_id: &str) -> Result<TankSession> {
590        let (result, stale) = {
591            let mut inner = self.lock_inner();
592            let stale = self.sweep_take(&mut inner);
593            let result = match inner.sessions.get_mut(session_id) {
594                Some(last) => {
595                    *last = Instant::now();
596                    Ok(TankSession {
597                        session_id: session_id.to_string(),
598                        lease: self.lease,
599                        viewers: inner.sessions.len(),
600                    })
601                }
602                None => Err(BusError::Protocol(format!(
603                    "tank session not found: {session_id}"
604                ))),
605            };
606            (result, stale)
607        };
608        Self::stop_taken(stale);
609        result
610    }
611
612    pub fn release(&self, session_id: &str) -> Result<TankStatus> {
613        let handle = {
614            let mut inner = self.lock_inner();
615            inner.sessions.remove(session_id);
616            if inner.sessions.is_empty() {
617                // Last viewer closed the window — tear the sim down now.
618                inner.stop_after = None;
619                inner.handle.take()
620            } else {
621                None
622            }
623        };
624        let stopped = handle.is_some();
625        Self::stop_taken(handle);
626        if stopped {
627            return Ok(TankStatus {
628                running: false,
629                viewers: 0,
630            });
631        }
632        Ok(self.status())
633    }
634
635    pub fn status(&self) -> TankStatus {
636        let handle = self.sweep_now();
637        let stopped = handle.is_some();
638        Self::stop_taken(handle);
639        if stopped {
640            return TankStatus {
641                running: false,
642                viewers: 0,
643            };
644        }
645        let inner = self.lock_inner();
646        TankStatus {
647            running: inner.handle.is_some(),
648            viewers: inner.sessions.len(),
649        }
650    }
651
652    /// Force-stop the sim and drop all sessions (broker shutdown).
653    pub fn shutdown(&self) {
654        let handle = {
655            let mut inner = self.lock_inner();
656            inner.sessions.clear();
657            inner.stop_after = None;
658            inner.handle.take()
659        };
660        Self::stop_taken(handle);
661    }
662
663    fn lock_inner(&self) -> std::sync::MutexGuard<'_, ManagerInner> {
664        self.inner.lock().unwrap_or_else(|e| e.into_inner())
665    }
666
667    fn sweep_now(&self) -> Option<TankHandle> {
668        let mut inner = self.lock_inner();
669        self.sweep_take(&mut inner)
670    }
671
672    fn stop_taken(handle: Option<TankHandle>) {
673        if let Some(handle) = handle {
674            handle.stop();
675        }
676    }
677
678    /// Wake up so a crashed viewer (lease expiry) still stops after `stop_grace`.
679    fn spawn_watch(weak: Weak<Self>, interval: Duration) {
680        let interval = interval.max(Duration::from_millis(200));
681        let _ = thread::Builder::new()
682            .name("robot-bus-tank-watch".into())
683            .spawn(move || {
684                loop {
685                    thread::sleep(interval);
686                    let Some(mgr) = weak.upgrade() else { break };
687                    let _ = mgr.status();
688                }
689            });
690    }
691
692    /// Expire leases and take the sim handle when the idle deadline has passed.
693    /// Caller must [`TankHandle::stop`] *after* dropping the mutex.
694    fn sweep_take(&self, inner: &mut ManagerInner) -> Option<TankHandle> {
695        let now = Instant::now();
696        inner
697            .sessions
698            .retain(|_, last| now.duration_since(*last) <= self.lease);
699
700        if inner.sessions.is_empty() {
701            let should_stop = match inner.stop_after {
702                Some(deadline) => now >= deadline,
703                None => {
704                    // Lease expiry emptied sessions without an explicit release.
705                    if inner.handle.is_some() {
706                        inner.stop_after = Some(now + self.stop_grace);
707                    }
708                    false
709                }
710            };
711            if should_stop {
712                inner.stop_after = None;
713                return inner.handle.take();
714            }
715            None
716        } else {
717            inner.stop_after = None;
718            None
719        }
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    #[test]
728    fn manager_tracks_sessions_without_bus() {
729        let mgr = TankManager::with_timing(
730            TankEndpoints {
731                message_xsub: "tcp://127.0.0.1:1".into(),
732                message_xpub: "tcp://127.0.0.1:1".into(),
733                service_frontend: "tcp://127.0.0.1:1".into(),
734                service_backend: "tcp://127.0.0.1:1".into(),
735                action_backend: "tcp://127.0.0.1:1".into(),
736            },
737            Duration::from_millis(200),
738            Duration::from_millis(50),
739        );
740
741        let st = mgr.status();
742        assert!(!st.running);
743        assert_eq!(st.viewers, 0);
744    }
745}