1use std::collections::{HashMap, HashSet};
19use std::future::Future;
20use std::pin::Pin;
21use std::sync::{Mutex, OnceLock};
22
23use tokio::sync::watch;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Policy {
28 Ask,
29 Always,
30 Never,
31}
32
33impl Policy {
34 pub fn parse(value: &str) -> Policy {
36 match value.trim().to_ascii_lowercase().as_str() {
37 "always" => Policy::Always,
38 "never" => Policy::Never,
39 _ => Policy::Ask,
40 }
41 }
42
43 pub fn resolve(host: &str, global: &str) -> Policy {
45 if host.trim().is_empty() {
46 Policy::parse(global)
47 } else {
48 Policy::parse(host)
49 }
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Step {
56 Install,
57 Refuse,
58 Ask,
59}
60
61pub fn step(policy: Policy, by_agent: bool) -> Step {
64 match (policy, by_agent) {
65 (_, true) | (Policy::Ask, false) => Step::Ask,
66 (Policy::Always, false) => Step::Install,
67 (Policy::Never, false) => Step::Refuse,
68 }
69}
70
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
73pub struct Answer {
74 pub answered: bool,
76 pub approve: bool,
78 pub remember: bool,
80}
81
82pub fn outcome(answer: Answer) -> (bool, Option<&'static str>) {
84 let install = answer.answered && answer.approve;
85 let remember = (answer.answered && answer.remember).then_some(if install { "always" } else { "never" });
86 (install, remember)
87}
88
89type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
90
91pub struct Deps {
93 pub ask: Box<dyn Fn(String, serde_json::Value) -> BoxFuture<Result<Answer, String>> + Send + Sync>,
96 pub settings: Box<dyn Fn(&str) -> (String, String) + Send + Sync>,
98 pub remember: Box<dyn Fn(&str, &str) -> Result<(), String> + Send + Sync>,
100}
101
102static DEPS: OnceLock<Deps> = OnceLock::new();
103
104pub fn install(deps: Deps) {
106 let _ = DEPS.set(deps);
107}
108
109#[cfg(test)]
111pub fn install_answering_yes() {
112 install(Deps {
113 ask: Box::new(|_, _| Box::pin(async { Ok(Answer { answered: true, approve: true, remember: false }) })),
114 settings: Box::new(|_| (String::new(), String::new())),
115 remember: Box::new(|_, _| Ok(())),
116 });
117}
118
119const MAX_AGENT_PANES: usize = 512;
122
123#[derive(Default)]
129struct AgentPanes {
130 by_block: HashMap<String, String>,
131 order: std::collections::VecDeque<String>,
132 opening: HashMap<String, u32>,
133}
134
135fn agent_panes() -> &'static Mutex<AgentPanes> {
136 static PANES: OnceLock<Mutex<AgentPanes>> = OnceLock::new();
137 PANES.get_or_init(|| Mutex::new(AgentPanes::default()))
138}
139
140fn lock() -> std::sync::MutexGuard<'static, AgentPanes> {
141 agent_panes().lock().unwrap_or_else(|e| e.into_inner())
142}
143
144type Hosts = OnceLock<Mutex<HashSet<String>>>;
146
147fn hosts(set: &'static Hosts) -> std::sync::MutexGuard<'static, HashSet<String>> {
148 set.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner())
149}
150
151fn declined() -> std::sync::MutexGuard<'static, HashSet<String>> {
153 static DECLINED: Hosts = OnceLock::new();
154 hosts(&DECLINED)
155}
156
157fn not_installed_hosts() -> std::sync::MutexGuard<'static, HashSet<String>> {
164 static NOT_INSTALLED: Hosts = OnceLock::new();
165 hosts(&NOT_INSTALLED)
166}
167
168pub fn not_installed(connection: &str) -> bool {
170 not_installed_hosts().contains(&host_key(connection))
171}
172
173pub fn note_not_installed(connection: &str) {
175 not_installed_hosts().insert(host_key(connection));
176}
177
178pub fn plain_instead(connection: &str) -> bool {
184 if unsupported_host(connection) {
185 return true;
186 }
187 let Some(deps) = DEPS.get() else {
188 return false;
189 };
190 let (host, global) = (deps.settings)(connection);
191 plain_for(
192 Policy::resolve(&host, &global),
193 declined().contains(&host_key(connection)),
194 )
195}
196
197fn unsupported() -> std::sync::MutexGuard<'static, HashSet<String>> {
199 static UNSUPPORTED: Hosts = OnceLock::new();
200 hosts(&UNSUPPORTED)
201}
202
203pub fn unsupported_host(connection: &str) -> bool {
205 unsupported().contains(&host_key(connection))
206}
207
208pub fn note_unsupported(connection: &str) {
211 unsupported().insert(host_key(connection));
212}
213
214fn plain_for(policy: Policy, declined: bool) -> bool {
217 match policy {
218 Policy::Always => false,
219 Policy::Never => true,
220 Policy::Ask => declined,
221 }
222}
223
224fn approved() -> std::sync::MutexGuard<'static, HashSet<String>> {
227 static APPROVED: Hosts = OnceLock::new();
228 hosts(&APPROVED)
229}
230
231pub fn forget_answers(connection: &str) {
235 let key = host_key(connection);
236 approved().remove(&key);
237 declined().remove(&key);
238 not_installed_hosts().remove(&key);
239}
240
241pub(crate) fn note_answer(connection: &str, answer: Answer) {
245 if !answer.answered {
246 return;
247 }
248 let key = host_key(connection);
249 if answer.approve {
250 declined().remove(&key);
251 approved().insert(key);
252 } else {
253 approved().remove(&key);
254 declined().insert(key);
255 }
256}
257
258fn host_key(connection: &str) -> String {
260 super::ConnTarget::parse(connection)
261 .map(|t| t.name())
262 .unwrap_or_else(|_| connection.trim().to_string())
263}
264
265pub fn note_agent_pane(block_id: &str, agent: &str) {
267 lock().note(block_id, agent);
268}
269
270impl AgentPanes {
271 fn note(&mut self, block_id: &str, agent: &str) {
273 if block_id.is_empty() {
274 return;
275 }
276 if self.by_block.insert(block_id.to_string(), agent.to_string()).is_none() {
277 self.order.push_back(block_id.to_string());
278 }
279 while self.order.len() > MAX_AGENT_PANES {
280 if let Some(old) = self.order.pop_front() {
281 self.by_block.remove(&old);
282 }
283 }
284 }
285}
286
287pub struct AgentOpening(String);
289
290impl Drop for AgentOpening {
291 fn drop(&mut self) {
292 let mut panes = lock();
293 if let Some(n) = panes.opening.get_mut(&self.0) {
294 *n = n.saturating_sub(1);
295 if *n == 0 {
296 panes.opening.remove(&self.0);
297 }
298 }
299 }
300}
301
302pub fn begin_agent_open(connection: &str) -> AgentOpening {
305 let key = host_key(connection);
306 *lock().opening.entry(key.clone()).or_default() += 1;
307 AgentOpening(key)
308}
309
310fn agent_of(block_id: Option<&str>) -> Option<String> {
311 let block_id = block_id.filter(|b| !b.is_empty())?;
312 lock().by_block.get(block_id).cloned()
313}
314
315fn agent_opening(connection: &str) -> bool {
316 lock().opening.contains_key(&host_key(connection))
317}
318
319pub fn question(connection: &str, agent: Option<&str>) -> serde_json::Value {
321 let who = match agent {
322 Some(a) => format!("{a} is using a pane on {connection} that needs it. "),
323 None => String::new(),
324 };
325 serde_json::json!({
326 "kind": "consent",
327 "title": format!("Install AgentMux's helper on {connection}?"),
328 "message": format!(
329 "{who}The helper enables file browsing and terminals that survive disconnects. \
330 It is about 500 KB, in ~/.agentmux-remote on that machine; it needs no root, \
331 and you can remove it any time from Connectors > Remotes."
332 ),
333 "checkbox": format!("Don't ask again for {connection}"),
334 "ok_label": "Install",
335 "cancel_label": "Not now",
336 })
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum Need {
343 Files,
345 Terminal { by_default: bool },
348}
349
350pub async fn allow_install(connection: &str, block_id: Option<&str>, need: Need) -> Result<(), String> {
354 let deps = DEPS
355 .get()
356 .ok_or_else(|| "AgentMux could not ask whether to install its helper".to_string())?;
357 allow_install_with(deps, connection, block_id, need).await
358}
359
360fn open_questions() -> std::sync::MutexGuard<'static, HashMap<String, watch::Receiver<Option<Answer>>>> {
363 static OPEN: OnceLock<Mutex<HashMap<String, watch::Receiver<Option<Answer>>>>> = OnceLock::new();
364 OPEN.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner())
365}
366
367fn held(connection: &str, key: &str, need: Need) -> Option<Result<(), String>> {
371 if need == (Need::Terminal { by_default: true }) && not_installed_hosts().contains(key) {
374 return Some(Err("it didn't install on this host earlier".to_string()));
375 }
376 if approved().contains(key) {
377 return Some(Ok(()));
378 }
379 match need {
380 Need::Terminal { .. } if declined().contains(key) => {
381 Some(Err(format!("you chose not to install AgentMux's helper on {connection}")))
382 }
383 _ => None,
384 }
385}
386
387async fn ask_once(deps: &Deps, connection: &str, block_id: &str, need: Need) -> Result<(), String> {
391 let key = host_key(connection);
392 let tx = loop {
393 let open = {
394 let mut open = open_questions();
395 match open.get(&key) {
396 Some(rx) => Err(rx.clone()),
397 None => {
398 if let Some(earlier) = held(connection, &key, need) {
399 return earlier;
400 }
401 let (tx, rx) = watch::channel(None);
402 open.insert(key.clone(), rx);
403 Ok(tx)
404 }
405 }
406 };
407 match open {
408 Ok(tx) => break tx,
409 Err(mut rx) => match rx.wait_for(Option::is_some).await {
410 Ok(answer) => return verdict(connection, answer.unwrap_or_default()),
411 Err(_) => continue,
415 },
416 }
417 };
418 let _open = OpenQuestion(key);
420 let answer = match (deps.ask)(block_id.to_string(), question(connection, None)).await {
421 Ok(answer) => answer,
422 Err(e) => {
423 tracing::warn!(connection = %connection, error = %e, "helper install: could not ask");
424 Answer::default()
425 }
426 };
427 record(deps, connection, None, answer);
428 if !answer.answered && need == (Need::Terminal { by_default: true }) {
431 note_not_installed(connection);
432 }
433 let _ = tx.send(Some(answer));
434 verdict(connection, answer)
435}
436
437fn verdict(connection: &str, answer: Answer) -> Result<(), String> {
439 if outcome(answer).0 {
440 Ok(())
441 } else if answer.answered {
442 Err(format!("you chose not to install AgentMux's helper on {connection}"))
443 } else {
444 Err(format!("nobody answered whether to install AgentMux's helper on {connection}"))
445 }
446}
447
448struct OpenQuestion(String);
450
451impl Drop for OpenQuestion {
452 fn drop(&mut self) {
453 open_questions().remove(&self.0);
454 }
455}
456
457fn record(deps: &Deps, connection: &str, agent: Option<&str>, answer: Answer) {
459 let (install, remember) = outcome(answer);
460 if let Some(value) = remember {
461 if let Err(e) = (deps.remember)(connection, value) {
462 tracing::warn!(connection = %connection, error = %e, "helper install: could not store the answer");
463 }
464 }
465 tracing::info!(connection = %connection, agent = ?agent, install, remember = ?remember, "helper install: asked");
466 note_answer(connection, answer);
467}
468
469async fn allow_install_with(deps: &Deps, connection: &str, block_id: Option<&str>, need: Need) -> Result<(), String> {
471 let agent = agent_of(block_id);
472 let by_agent = agent.is_some() || block_id.is_none_or(str::is_empty) || agent_opening(connection);
473 let (host, global) = (deps.settings)(connection);
474 match step(Policy::resolve(&host, &global), by_agent) {
475 Step::Install => return Ok(()),
476 Step::Refuse => {
477 return Err(format!(
478 "installing AgentMux's helper on {connection} is set to Never (change it in Connectors > Remotes)"
479 ))
480 }
481 Step::Ask => {}
482 }
483 if !by_agent {
484 return ask_once(deps, connection, block_id.unwrap_or_default(), need).await;
485 }
486 let answer = (deps.ask)(
488 block_id.unwrap_or_default().to_string(),
489 question(connection, agent.as_deref()),
490 )
491 .await?;
492 record(deps, connection, agent.as_deref(), answer);
493 verdict(connection, answer)
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[tokio::test]
503 async fn a_hosts_panes_share_one_question() {
504 use std::sync::atomic::{AtomicUsize, Ordering};
505 let asked = std::sync::Arc::new(AtomicUsize::new(0));
506 let deps = {
507 let asked = asked.clone();
508 Deps {
509 ask: Box::new(move |_, _| {
510 asked.fetch_add(1, Ordering::SeqCst);
511 Box::pin(async {
512 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
513 Ok(Answer { answered: true, approve: true, remember: false })
514 })
515 }),
516 settings: Box::new(|_| (String::new(), String::new())),
517 remember: Box::new(|_, _| Ok(())),
518 }
519 };
520 let (a, b, c) = tokio::join!(
521 allow_install_with(&deps, "me@shared-question-host", Some("blk-shared-1"), Need::Files),
522 allow_install_with(&deps, " me@shared-question-host ", Some("blk-shared-2"), Need::Files),
523 allow_install_with(&deps, "me@other-question-host", Some("blk-shared-3"), Need::Files),
524 );
525 assert_eq!((a, b, c), (Ok(()), Ok(()), Ok(())));
526 assert_eq!(asked.load(Ordering::SeqCst), 2, "one question per host");
527
528 let later = allow_install_with(&deps, "me@shared-question-host", Some("blk-shared-4"), Need::Files).await;
531 assert_eq!(later, Ok(()));
532 assert_eq!(asked.load(Ordering::SeqCst), 2, "Install holds until srv restarts");
533
534 forget_answers(" me@shared-question-host ");
536 let after_removal = allow_install_with(&deps, "me@shared-question-host", Some("blk-shared-5"), Need::Files).await;
537 assert_eq!(after_removal, Ok(()));
538 assert_eq!(asked.load(Ordering::SeqCst), 3, "asked again after the helper was removed");
539
540 }
541
542 #[tokio::test]
546 async fn what_an_earlier_answer_means_depends_on_the_need() {
547 use std::sync::atomic::{AtomicUsize, Ordering};
548 static ASKED: AtomicUsize = AtomicUsize::new(0);
549 static DEPS_COUNTS: OnceLock<Deps> = OnceLock::new();
550 let deps = DEPS_COUNTS.get_or_init(|| Deps {
551 ask: Box::new(|_, _| {
552 ASKED.fetch_add(1, Ordering::SeqCst);
553 Box::pin(async { Ok(Answer { answered: true, approve: false, remember: false }) })
554 }),
555 settings: Box::new(|_| (String::new(), String::new())),
556 remember: Box::new(|_, _| Ok(())),
557 });
558 let terminal = Need::Terminal { by_default: true };
559 note_answer("me@need-declined-host", Answer { answered: true, approve: false, remember: false });
560 assert!(allow_install_with(deps, "me@need-declined-host", Some("blk-need-1"), terminal).await.is_err());
561 assert_eq!(ASKED.load(Ordering::SeqCst), 0, "a terminal isn't asked again after Not now");
562 let _ = allow_install_with(deps, "me@need-declined-host", Some("blk-need-2"), Need::Files).await;
563 assert_eq!(ASKED.load(Ordering::SeqCst), 1, "Files' Reconnect asks again");
564
565 note_not_installed("me@need-failed-host");
566 let failed = allow_install_with(deps, "me@need-failed-host", Some("blk-need-3"), terminal).await;
567 assert_eq!(failed, Err("it didn't install on this host earlier".to_string()));
568 assert_eq!(ASKED.load(Ordering::SeqCst), 1);
569 let chosen = Need::Terminal { by_default: false };
570 let _ = allow_install_with(deps, "me@need-failed-host", Some("blk-need-4"), chosen).await;
571 assert_eq!(ASKED.load(Ordering::SeqCst), 2, "a terminal set to durable asks");
572 }
573
574 #[tokio::test]
579 async fn a_failed_or_unanswered_try_holds_for_default_terminals() {
580 use std::sync::atomic::{AtomicUsize, Ordering};
581 static ASKED: AtomicUsize = AtomicUsize::new(0);
582 static DEPS_NO_ANSWER: OnceLock<Deps> = OnceLock::new();
583 let deps = DEPS_NO_ANSWER.get_or_init(|| Deps {
584 ask: Box::new(|_, _| {
585 ASKED.fetch_add(1, Ordering::SeqCst);
586 Box::pin(async { Ok(Answer::default()) })
587 }),
588 settings: Box::new(|_| (String::new(), String::new())),
589 remember: Box::new(|_, _| Ok(())),
590 });
591 let terminal = Need::Terminal { by_default: true };
592 note_answer("me@approved-failed-host", Answer { answered: true, approve: true, remember: false });
593 note_not_installed("me@approved-failed-host");
594 let retry = allow_install_with(deps, "me@approved-failed-host", Some("blk-af-1"), terminal).await;
595 assert!(retry.is_err(), "a failed install outweighs the Install answer");
596 let files = allow_install_with(deps, "me@approved-failed-host", Some("blk-af-2"), Need::Files).await;
597 assert_eq!(files, Ok(()), "Files tries again on the Install answer");
598 assert_eq!(ASKED.load(Ordering::SeqCst), 0);
599
600 let unanswered = allow_install_with(deps, "me@unanswered-host", Some("blk-af-3"), terminal).await;
601 assert!(unanswered.is_err());
602 assert!(not_installed("me@unanswered-host"), "noted before the question closed");
603 }
604
605 #[tokio::test]
608 async fn a_waiting_pane_asks_when_the_asking_pane_closes() {
609 use std::sync::atomic::{AtomicUsize, Ordering};
610 static ASKED: AtomicUsize = AtomicUsize::new(0);
611 static DEPS_SECOND_ANSWERS: OnceLock<Deps> = OnceLock::new();
612 let deps = DEPS_SECOND_ANSWERS.get_or_init(|| Deps {
613 ask: Box::new(|_, _| {
614 if ASKED.fetch_add(1, Ordering::SeqCst) == 0 {
615 Box::pin(std::future::pending())
616 } else {
617 Box::pin(async { Ok(Answer { answered: true, approve: true, remember: false }) })
618 }
619 }),
620 settings: Box::new(|_| (String::new(), String::new())),
621 remember: Box::new(|_, _| Ok(())),
622 });
623 let asking = tokio::spawn(allow_install_with(deps, "me@handover-host", Some("blk-handover-1"), Need::Files));
624 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
625 let waiting = tokio::spawn(allow_install_with(deps, "me@handover-host", Some("blk-handover-2"), Need::Files));
626 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
627 asking.abort();
628 let waited = tokio::time::timeout(std::time::Duration::from_secs(5), waiting).await;
629 assert_eq!(waited.expect("the waiting pane gets an answer").unwrap(), Ok(()));
630 assert_eq!(ASKED.load(Ordering::SeqCst), 2, "asked again, by the pane still open");
631 }
632
633 #[tokio::test]
636 async fn a_question_whose_pane_closed_is_not_left_open() {
637 static DEPS_NEVER_ANSWERS: OnceLock<Deps> = OnceLock::new();
638 let deps = DEPS_NEVER_ANSWERS.get_or_init(|| Deps {
639 ask: Box::new(|_, _| Box::pin(std::future::pending())),
640 settings: Box::new(|_| (String::new(), String::new())),
641 remember: Box::new(|_, _| Ok(())),
642 });
643 let asking = tokio::spawn(allow_install_with(deps, "me@closed-question-host", Some("blk-closed"), Need::Files));
644 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
645 assert!(open_questions().contains_key("me@closed-question-host"));
646 asking.abort();
647 let _ = asking.await;
648 assert!(!open_questions().contains_key("me@closed-question-host"));
649 }
650
651 #[test]
652 fn the_hosts_setting_wins_over_the_global_one() {
653 assert_eq!(Policy::resolve("", ""), Policy::Ask);
654 assert_eq!(Policy::resolve("", "Always"), Policy::Always);
655 assert_eq!(Policy::resolve("never", "always"), Policy::Never);
656 assert_eq!(Policy::resolve("ask", "never"), Policy::Ask);
657 assert_eq!(Policy::resolve("nonsense", "always"), Policy::Ask);
658 }
659
660 #[test]
661 fn an_agents_install_is_always_asked_about() {
662 assert_eq!(step(Policy::Always, false), Step::Install);
663 assert_eq!(step(Policy::Never, false), Step::Refuse);
664 assert_eq!(step(Policy::Ask, false), Step::Ask);
665 for p in [Policy::Ask, Policy::Always, Policy::Never] {
666 assert_eq!(step(p, true), Step::Ask, "{p:?}");
667 }
668 }
669
670 #[test]
671 fn the_four_answers() {
672 let a = |approve, remember| Answer { answered: true, approve, remember };
673 assert_eq!(outcome(a(true, false)), (true, None), "Install");
674 assert_eq!(outcome(a(true, true)), (true, Some("always")), "Always for this host");
675 assert_eq!(outcome(a(false, false)), (false, None), "Not now");
676 assert_eq!(outcome(a(false, true)), (false, Some("never")), "Never for this host");
677 let closed = Answer { answered: false, approve: false, remember: true };
678 assert_eq!(outcome(closed), (false, None), "a closed window stores nothing");
679 }
680
681 #[test]
682 fn a_host_the_user_doesnt_want_the_helper_on_gets_a_plain_terminal() {
683 assert!(plain_for(Policy::Never, false), "Never");
684 assert!(plain_for(Policy::Ask, true), "Not now, since srv started");
685 assert!(!plain_for(Policy::Ask, false), "not asked yet");
686 assert!(!plain_for(Policy::Always, true), "Always wins over an earlier Not now");
687 }
688
689 #[test]
690 fn not_now_is_remembered_until_an_install_and_a_closed_window_is_no_answer() {
691 let a = |answered, approve| Answer { answered, approve, remember: false };
692 let host = "me@declined-host";
693 note_answer(host, a(false, false));
694 assert!(!declined().contains(host), "a closed window");
695 note_answer(&format!(" {host} "), a(true, false));
696 assert!(declined().contains(host), "Not now, however the host is spelled");
697 note_answer(host, a(true, true));
698 assert!(!declined().contains(host), "a later Install");
699 }
700
701 #[test]
702 fn a_host_the_helper_cant_run_on_gets_a_plain_terminal_whatever_the_setting() {
703 assert!(!plain_instead("me@windows-host"));
704 note_unsupported(" me@windows-host ");
705 assert!(plain_instead("me@windows-host"));
706 }
707
708 #[test]
709 fn the_question_names_the_host_and_the_agent() {
710 let q = question("db1", Some("korp"));
711 assert_eq!(q["kind"], "consent");
712 assert!(q["title"].as_str().unwrap().contains("db1"));
713 assert!(q["message"].as_str().unwrap().starts_with("korp is using"));
714 assert_eq!(q["ok_label"], "Install");
715 assert!(!question("db1", None)["message"].as_str().unwrap().contains("is using"));
716 }
717
718 #[test]
719 fn a_pane_an_agent_opened_is_the_agents() {
720 note_agent_pane("blk-agent-opened", "korp");
721 assert_eq!(agent_of(Some("blk-agent-opened")).as_deref(), Some("korp"));
722 assert_eq!(agent_of(Some("blk-users-own")), None);
723 assert_eq!(agent_of(None), None);
724 }
725
726 #[test]
727 fn a_host_an_agent_is_opening_a_pane_on_asks() {
728 assert!(!agent_opening("me@opening-host"));
729 let first = begin_agent_open("me@opening-host");
730 let second = begin_agent_open(" me@opening-host ");
731 assert!(agent_opening("me@opening-host"), "however it is spelled");
732 assert!(!agent_opening("me@other-host"));
733 drop(first);
734 assert!(agent_opening("me@opening-host"), "one still in flight");
735 drop(second);
736 assert!(!agent_opening("me@opening-host"));
737 }
738
739 #[test]
742 fn the_agent_panes_kept_are_bounded() {
743 let mut panes = AgentPanes::default();
744 for i in 0..MAX_AGENT_PANES + 10 {
745 panes.note(&format!("blk-bound-{i}"), "korp");
746 }
747 assert!(panes.by_block.len() <= MAX_AGENT_PANES);
748 assert_eq!(panes.by_block.len(), panes.order.len());
749 assert!(!panes.by_block.contains_key("blk-bound-0"), "the oldest went");
750 }
751}