agentmux_srv/backend/remote/
status.rs1use std::collections::BTreeMap;
13use std::sync::{Mutex, OnceLock};
14
15use crate::backend::mps::Broker;
16use crate::backend::rpc_types::ConnStatus;
17
18pub mod state {
20 pub const CONNECTING: &str = "connecting";
21 pub const CONNECTED: &str = "connected";
22 pub const DISCONNECTED: &str = "disconnected";
23 pub const ERROR: &str = "error";
24}
25
26fn registry() -> &'static Mutex<BTreeMap<String, ConnStatus>> {
27 static REG: OnceLock<Mutex<BTreeMap<String, ConnStatus>>> = OnceLock::new();
28 REG.get_or_init(|| Mutex::new(BTreeMap::new()))
29}
30
31pub fn set(
34 broker: Option<&Broker>,
35 connection: &str,
36 status: &str,
37 error: Option<&str>,
38) -> ConnStatus {
39 update(broker, connection, |entry| apply(entry, status, error))
40}
41
42pub fn pane_started(broker: Option<&Broker>, connection: &str, logged_in: bool) -> ConnStatus {
49 update(broker, connection, |entry| {
50 entry.activeconnnum += 1;
51 let up = logged_in || entry.status == state::CONNECTED;
52 apply(entry, if up { state::CONNECTED } else { state::CONNECTING }, None);
53 })
54}
55
56pub fn pane_ended(
63 broker: Option<&Broker>,
64 connection: &str,
65 error: Option<&str>,
66 settle: bool,
67) -> ConnStatus {
68 update(broker, connection, |entry| {
69 entry.activeconnnum = (entry.activeconnnum - 1).max(0);
70 if entry.activeconnnum == 0 {
71 match error {
72 Some(message) => apply(entry, state::ERROR, Some(message)),
73 None if settle => apply(entry, state::DISCONNECTED, None),
74 None => {}
75 }
76 }
77 })
78}
79
80pub fn get(connection: &str) -> Option<ConnStatus> {
82 registry()
83 .lock()
84 .unwrap_or_else(|e| e.into_inner())
85 .get(connection)
86 .cloned()
87}
88
89fn apply(entry: &mut ConnStatus, status: &str, error: Option<&str>) {
90 entry.status = status.to_string();
91 entry.connected = status == state::CONNECTED;
92 if entry.connected {
93 entry.hasconnected = true;
94 }
95 entry.error = if status == state::ERROR {
96 error.unwrap_or_default().to_string()
97 } else {
98 String::new()
99 };
100}
101
102fn update(
103 broker: Option<&Broker>,
104 connection: &str,
105 change: impl FnOnce(&mut ConnStatus),
106) -> ConnStatus {
107 let updated = {
108 let mut map = registry().lock().unwrap_or_else(|e| e.into_inner());
109 let entry = map
110 .entry(connection.to_string())
111 .or_insert_with(|| ConnStatus {
112 status: state::DISCONNECTED.to_string(),
113 connection: connection.to_string(),
114 connected: false,
115 hasconnected: false,
116 activeconnnum: 0,
117 error: String::new(),
118 });
119 change(entry);
120 entry.clone()
121 };
122 #[cfg(not(test))]
128 let recorded = updated.connected && super::remotes::record_recent(connection);
129 #[cfg(test)]
130 let recorded = false;
131 if let Some(broker) = broker {
132 broker.publish_event::<crate::backend::mps_events::ConnChange>(&updated, vec![], 0);
133 if recorded {
134 super::remotes::publish_change(broker);
135 }
136 }
137 updated
138}
139
140pub fn all() -> Vec<ConnStatus> {
142 registry()
143 .lock()
144 .unwrap_or_else(|e| e.into_inner())
145 .values()
146 .cloned()
147 .collect()
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn status_moves_and_remembers_it_once_connected() {
156 let name = "test-status-host-a";
157 let s = set(None, name, state::CONNECTING, None);
158 assert_eq!((s.connected, s.hasconnected), (false, false));
159 let s = set(None, name, state::CONNECTED, None);
160 assert_eq!((s.connected, s.hasconnected), (true, true));
161 let s = set(None, name, state::ERROR, Some("Connection refused"));
162 assert_eq!(
163 (s.connected, s.hasconnected, s.error.as_str()),
164 (false, true, "Connection refused")
165 );
166 let s = set(None, name, state::CONNECTING, Some("ignored"));
167 assert!(s.error.is_empty(), "an error is cleared by the next state");
168 assert!(all().iter().any(|c| c.connection == name));
169 }
170
171 #[test]
174 fn a_connection_stays_connected_while_any_pane_has_it_open() {
175 let name = "test-status-two-panes";
176 pane_started(None, name, true);
177 let s = pane_started(None, name, true);
178 assert_eq!((s.status.as_str(), s.activeconnnum), (state::CONNECTED, 2));
179 let s = pane_ended(None, name, Some("could not connect"), true);
180 assert_eq!((s.status.as_str(), s.activeconnnum), (state::CONNECTED, 1));
181 let s = pane_ended(None, name, Some("could not connect"), true);
182 assert_eq!(
183 (s.status.as_str(), s.activeconnnum, s.error.as_str()),
184 (state::ERROR, 0, "could not connect")
185 );
186
187 let name = "test-status-clean-exit";
188 pane_started(None, name, true);
189 let s = pane_ended(None, name, None, true);
190 assert_eq!(
191 (s.status.as_str(), s.activeconnnum),
192 (state::DISCONNECTED, 0)
193 );
194 let s = pane_ended(None, name, None, true);
195 assert_eq!(s.activeconnnum, 0, "never below zero");
196 }
197
198 #[test]
203 fn a_starting_ssh_is_connecting_until_its_login_is_known() {
204 let name = "test-status-login-pending";
205 let s = pane_started(None, name, false);
206 assert_eq!((s.status.as_str(), s.connected, s.hasconnected), (state::CONNECTING, false, false));
207 let s = set(None, name, state::CONNECTED, None);
208 assert!(s.connected);
209 let s = pane_started(None, name, false);
210 assert_eq!((s.status.as_str(), s.activeconnnum), (state::CONNECTED, 2), "joins the live connection");
211 }
212
213 #[test]
217 fn a_clean_last_end_is_left_to_the_watcher_unless_settled() {
218 let name = "test-status-unsettled";
219 pane_started(None, name, true);
220 let s = pane_ended(None, name, None, false);
221 assert_eq!((s.status.as_str(), s.activeconnnum), (state::CONNECTED, 0));
222 assert_eq!(get(name).map(|g| g.status), Some(state::CONNECTED.to_string()));
223 pane_started(None, name, true);
224 let s = pane_ended(None, name, Some("Shared connection closed"), false);
225 assert_eq!((s.status.as_str(), s.error.as_str()), (state::ERROR, "Shared connection closed"));
226 assert!(get("test-status-never-seen").is_none());
227 }
228}