1use std::collections::HashMap;
21use std::sync::{Arc, Mutex, OnceLock};
22use std::time::{Duration, Instant};
23
24use agentmux_remote::fsproto::ProcFrame;
25
26use crate::backend::remote::files::RemoteFiles;
27use crate::backend::remote::sessions::AskIn;
28use crate::backend::remote::ConnTarget;
29use crate::backend::rpc_types::{TowerHost, TowerProcess, TowerSnapshot};
30
31pub const TOP: u32 = 100;
33
34const REUSE_WITHIN: Duration = Duration::from_millis(900);
36
37const WSL_IDLE_CLOSE: Duration = Duration::from_secs(120);
39
40pub fn is_local(conn: &str) -> bool {
42 matches!(ConnTarget::parse(conn), Ok(ConnTarget::Local)) || conn.trim().is_empty()
43}
44
45pub async fn sample(conn: &str, filter: &str, ask: Option<AskIn<'_>>) -> Result<TowerSnapshot, String> {
48 let slot = slot((conn.to_string(), filter.to_string()));
52 let mut slot = slot.lock().await;
53 if let Some((at, snap)) = slot.as_ref() {
54 if at.elapsed() < REUSE_WITHIN {
55 return Ok(snap.clone());
56 }
57 }
58 let helper = match ConnTarget::parse(conn)? {
59 ConnTarget::Ssh(_) => crate::backend::remote::files::connect(conn, ask).await.map_err(|e| e.message)?,
60 ConnTarget::Wsl(distro) => wsl_helper(&distro).await?,
61 ConnTarget::Local => return Err("tower: this computer is sampled locally".to_string()),
62 };
63 let frame = helper.procs(TOP, filter).await.map_err(|e| e.message)?;
64 let snap = to_snapshot(conn, &helper.os, frame);
65 *slot = Some((Instant::now(), snap.clone()));
66 Ok(snap)
67}
68
69type Slot = Arc<tokio::sync::Mutex<Option<(Instant, TowerSnapshot)>>>;
70
71fn slot(key: (String, String)) -> Slot {
75 static SLOTS: OnceLock<Mutex<HashMap<(String, String), Slot>>> = OnceLock::new();
76 let mut slots = SLOTS.get_or_init(Default::default).lock().unwrap();
77 slots.retain(|k, s| {
78 *k == key
79 || Arc::strong_count(s) > 1
80 || s.try_lock().map_or(true, |g| g.as_ref().is_some_and(|(at, _)| at.elapsed() < STALE_SLOT))
81 });
82 slots.entry(key).or_default().clone()
83}
84
85const STALE_SLOT: Duration = Duration::from_secs(60);
87
88pub fn to_snapshot(conn: &str, os: &str, f: ProcFrame) -> TowerSnapshot {
91 let rows: Vec<TowerProcess> = f
92 .rows
93 .into_iter()
94 .map(|r| TowerProcess {
95 id: format!("{}:{}", r.pid, r.start_key),
96 pid: r.pid,
97 ppid: r.ppid,
98 name: r.name,
99 started_at_ms: r.started_at_ms,
100 cpu: r.cpu_milli.map(|m| m as f64 / 1000.0),
101 mem: r.mem,
102 mem_resident: r.mem_resident,
103 mem_commit: None,
104 role: None,
105 task: None,
106 detail: None,
107 ..Default::default()
108 })
109 .collect();
110 TowerSnapshot {
111 ts_ms: agentmux_common::time::now_ms() as u64,
112 hostname: conn.to_string(),
113 os: os.to_string(),
114 cpu_count: f.cpu_count,
115 memory_metric: f.memory_metric,
116 interval_ms: crate::backend::tower_sampler::INTERVAL.as_millis() as u32,
117 remote: true,
118 tasks: Vec::new(),
119 machine: None,
121 host: Some(TowerHost {
122 processes: rows,
123 unmeasured: f.unmeasured,
124 cpu: f.cpu_milli.map(|m| m as f64 / 1000.0),
125 mem: f.mem,
126 total: f.total,
127 matched: f.matched,
128 }),
129 }
130}
131
132struct WslHelper {
135 files: Arc<RemoteFiles>,
136 last_used: Mutex<Instant>,
137}
138
139fn wsl_pool() -> &'static tokio::sync::Mutex<HashMap<String, Arc<WslHelper>>> {
140 static POOL: OnceLock<tokio::sync::Mutex<HashMap<String, Arc<WslHelper>>>> = OnceLock::new();
141 POOL.get_or_init(|| {
142 tokio::spawn(async {
144 loop {
145 tokio::time::sleep(Duration::from_secs(30)).await;
146 wsl_pool()
147 .lock()
148 .await
149 .retain(|_, h| h.files.is_open() && h.last_used.lock().unwrap().elapsed() < WSL_IDLE_CLOSE);
150 }
151 });
152 Default::default()
153 })
154}
155
156fn wsl_target() -> &'static str {
158 if cfg!(target_arch = "aarch64") {
159 "aarch64-unknown-linux-musl"
160 } else {
161 "x86_64-unknown-linux-musl"
162 }
163}
164
165pub(crate) async fn wsl_helper(distro: &str) -> Result<Arc<RemoteFiles>, String> {
168 let mut pool = wsl_pool().lock().await;
169 if let Some(h) = pool.get(distro).filter(|h| h.files.is_open()) {
170 *h.last_used.lock().unwrap() = Instant::now();
171 return Ok(h.files.clone());
172 }
173 let files = start_wsl(distro).await?;
174 pool.insert(distro.to_string(), Arc::new(WslHelper { files: files.clone(), last_used: Mutex::new(Instant::now()) }));
175 Ok(files)
176}
177
178#[cfg(windows)]
179async fn start_wsl(distro: &str) -> Result<Arc<RemoteFiles>, String> {
180 use agentmux_common::win32::NoWindow;
181 let helper = crate::backend::remote::helper_install::local_path(env!("CARGO_PKG_VERSION"), wsl_target())?;
182 let dir = helper.parent().ok_or("the helper has no folder")?;
183 let file = helper.file_name().ok_or("the helper has no name")?.to_string_lossy().into_owned();
186 let mut cmd = tokio::process::Command::new("wsl.exe");
187 crate::backend::pane_env::sanitize_external_command(&mut cmd);
189 cmd.arg("-d")
190 .arg(distro)
191 .arg("--cd")
192 .arg(dir)
193 .arg("--exec")
194 .arg(format!("./{file}"))
195 .args(["serve", "--stdio"])
196 .stdin(std::process::Stdio::piped())
197 .stdout(std::process::Stdio::piped())
198 .stderr(std::process::Stdio::null())
199 .kill_on_drop(true)
200 .no_window();
201 let mut child = cmd.spawn().map_err(|e| format!("couldn't start WSL ({distro}): {e}"))?;
202 let (Some(stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else {
203 return Err("WSL started without its pipes".to_string());
204 };
205 let conn = format!("wsl://{distro}");
206 RemoteFiles::over(&conn, stdout, stdin, Box::new(child)).await.map_err(|e| e.message)
207}
208
209#[cfg(not(windows))]
210async fn start_wsl(distro: &str) -> Result<Arc<RemoteFiles>, String> {
211 let _ = wsl_target();
212 Err(format!("WSL ({distro}) is only on Windows"))
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use agentmux_remote::fsproto::ProcRow;
219
220 #[test]
221 fn a_frame_becomes_a_host_only_snapshot() {
222 let snap = to_snapshot(
223 "build-box",
224 "linux",
225 ProcFrame {
226 cpu_count: 16,
227 memory_metric: "anonymous resident + swap".into(),
228 total: 410,
229 matched: 410,
230 unmeasured: 0,
231 cpu_milli: Some(2500),
232 mem: 8 << 30,
233 rows: vec![ProcRow {
234 pid: 77,
235 ppid: Some(1),
236 start_key: 123,
237 started_at_ms: Some(1),
238 name: "cargo".into(),
239 cpu_milli: Some(1250),
240 mem: Some(1 << 30),
241 mem_resident: None,
242 }],
243 },
244 );
245 assert!(snap.remote && snap.tasks.is_empty());
246 assert_eq!((snap.hostname.as_str(), snap.os.as_str(), snap.cpu_count), ("build-box", "linux", 16));
247 let host = snap.host.unwrap();
248 assert_eq!((host.total, host.processes.len()), (410, 1));
249 assert!((host.cpu.unwrap() - 2.5).abs() < 1e-9);
250 let p = &host.processes[0];
251 assert_eq!(p.id, "77:123");
252 assert_eq!(p.cpu, Some(1.25));
253 }
254
255 #[test]
256 fn this_computer_is_local() {
257 assert!(is_local(""));
258 assert!(is_local("local"));
259 assert!(!is_local("user@build-box"));
260 assert!(!is_local("wsl://Ubuntu"));
261 }
262
263 #[tokio::test]
268 #[cfg(windows)]
269 async fn a_wsl_distribution_is_sampled_through_the_bundled_helper() {
270 let Ok(distro) = std::env::var("AGENTMUX_TEST_WSL_DISTRO") else { return };
271 let conn = format!("wsl://{distro}");
272 let first = sample(&conn, "", None).await.unwrap();
273 let host = first.host.as_ref().unwrap();
274 assert_eq!(first.os, "linux");
275 assert!(host.total > 0 && !host.processes.is_empty(), "{first:?}");
276 assert!(host.processes.iter().any(|p| p.pid == 1), "PID 1 (init) is listed");
277 tokio::time::sleep(Duration::from_millis(1200)).await;
278 let second = sample(&conn, "", None).await.unwrap();
279 assert!(second.host.unwrap().processes.iter().any(|p| p.cpu.is_some()), "rates on the second frame");
280 }
281
282 #[tokio::test]
285 async fn an_ssh_host_is_sampled_through_its_helper() {
286 let home = tempfile::tempdir().unwrap();
287 crate::backend::remote::files::connect_in_process("tower-test-host", home.path().to_path_buf()).await;
288 let me = std::process::id().to_string();
289 let snap = sample("tower-test-host", &me, None).await.unwrap();
290 let host = snap.host.unwrap();
291 assert!(host.processes.iter().any(|p| p.pid == std::process::id()), "{host:?}");
292 assert!(host.total > host.matched || host.total == host.matched);
293 let again = sample("tower-test-host", &me, None).await.unwrap();
295 assert_eq!(again.ts_ms, snap.ts_ms);
296 }
297
298 #[tokio::test]
301 async fn concurrent_asks_share_one_frame() {
302 let home = tempfile::tempdir().unwrap();
303 crate::backend::remote::files::connect_in_process("tower-test-host-2", home.path().to_path_buf()).await;
304 let (a, b) = tokio::join!(sample("tower-test-host-2", "x", None), sample("tower-test-host-2", "x", None));
305 assert_eq!(a.unwrap().ts_ms, b.unwrap().ts_ms);
306 }
307}