Skip to main content

agentmux_srv/backend/remote/
status.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Each connection's status, published as `connchange` events.
5//!
6//! The frontend already has the consumer, from the Wave code AgentMux grew out
7//! of: `conn-status.ts` loads every status with `GetAllConnStatus` and then
8//! follows `connchange` events, and `ConnStatusOverlay` shows a pane's
9//! connection state with Reconnect and Disconnect actions. This is the producer
10//! it never had. `local` is never listed: it is always connected.
11
12use std::collections::BTreeMap;
13use std::sync::{Mutex, OnceLock};
14
15use crate::backend::mps::Broker;
16use crate::backend::rpc_types::ConnStatus;
17
18/// The status words the frontend's overlay understands.
19pub 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
31/// Record a connection's new state and tell the frontend. `error` is shown in
32/// the pane's overlay; it is cleared on any non-error state.
33pub 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
42/// A pane's process on `connection` started (an SSH pane's `ssh`, a durable
43/// pane's attach): one more pane has it open. `logged_in` says it is a live
44/// login already (a durable pane's helper answered, or nothing better can be
45/// known); otherwise it is only connecting, its `ssh` perhaps waiting at a
46/// password prompt, until the shared connection answers (`remote::link`). A
47/// connection already up stays up.
48pub 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
56/// A pane's process on `connection` ended. A status is shared by every pane on
57/// the connection, and the overlay covers each of them, so while another pane
58/// still has it open nothing changes. The last one leaves it `error` with
59/// `error` when it could not connect or lost the connection. Otherwise it is
60/// `disconnected` if `settle`; else it is left for the shared connection's
61/// watcher (`remote::link`), since that connection can outlive its last pane.
62pub 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
80/// `connection`'s status, if it has one.
81pub 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    // An SSH connection that reached `connected` joins Connectors → Remotes' Recent
123    // section (SPEC_REMOTES_PANE_2026_10_05.md §4.5): recorded before the events
124    // below, so a pane refreshing on them already sees it. Not in unit tests,
125    // which would write the file into whatever config dir the test process
126    // resolves.
127    #[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
140/// Every known connection's status, for `GetAllConnStatus`.
141pub 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    /// Two panes on one host: the first one ending, even with a failure, does
172    /// not cover the second, still-open one; the last one says why.
173    #[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    /// A pane's ssh starting is not a login: it may be waiting at a password
199    /// prompt. The host was shown connected the moment it started (#4513).
200    /// It stays connecting until the login is known, and a connection already
201    /// up stays up when another pane joins it.
202    #[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    /// The shared connection can outlive its last pane (`ControlPersist`):
214    /// unless told to settle, a clean last end leaves the status to the
215    /// watcher that asks the connection. A failure still says why at once.
216    #[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}