1use super::*;
2
3#[cfg(unix)]
4use std::fs;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Condvar, Mutex, OnceLock};
7use std::time::Duration;
8
9pub fn ssh_connectivity_probe(ssh: &SshTarget) -> CommandSpec {
24 let mut args = vec![
25 "-o".to_owned(),
26 "BatchMode=yes".to_owned(),
27 "-o".to_owned(),
28 "StrictHostKeyChecking=yes".to_owned(),
29 ];
30 args.extend(ssh.ssh_args.iter().cloned());
31 push_connection_reuse_args(&mut args, ssh);
34 args.push(ssh.destination.clone());
35 args.push(join_remote_command(&["true".to_owned()]));
36 CommandSpec::new("ssh", args)
37 .ssh_destination(ssh.destination.clone())
38 .purpose("verify SSH connectivity")
39}
40
41pub fn ssh_command(
42 ssh: &SshTarget,
43 args: impl IntoIterator<Item = impl AsRef<str>>,
44) -> CommandSpec {
45 ssh_command_owned(
46 ssh,
47 args.into_iter()
48 .map(|arg| arg.as_ref().to_owned())
49 .collect(),
50 )
51}
52
53pub fn ssh_command_owned(ssh: &SshTarget, remote_args: Vec<String>) -> CommandSpec {
57 let mut args = ssh.ssh_args.clone();
58 args.push(ssh.destination.clone());
59 args.push(join_remote_command(&remote_args));
60 CommandSpec::new("ssh", args).ssh_session(ssh)
61}
62
63pub const REMOTE_UPLOAD_STAGING: &str = ".cache/mjolnir/uploads";
67
68pub fn scp_upload(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
70 let mut args = scp_args(ssh);
71 if recursive {
72 args.push("-r".into());
73 }
74 args.push(source.to_string_lossy().into_owned());
75 args.push(format!("{}:{remote}", ssh.destination));
76 scp_command(ssh, args)
77}
78
79pub fn scp_download(ssh: &SshTarget, remote: &str, local: &str) -> CommandSpec {
81 let mut args = scp_args(ssh);
82 args.push(format!("{}:{remote}", ssh.destination));
83 args.push(local.into());
84 scp_command(ssh, args)
85}
86
87fn scp_args(ssh: &SshTarget) -> Vec<String> {
90 ssh.ssh_args
91 .iter()
92 .map(|argument| {
93 if argument == "-p" {
94 "-P".to_owned()
95 } else {
96 argument.clone()
97 }
98 })
99 .collect()
100}
101
102fn scp_command(ssh: &SshTarget, args: Vec<String>) -> CommandSpec {
103 CommandSpec::new("scp", args).ssh_session(ssh)
106}
107
108#[cfg(unix)]
113const CONTROL_PERSIST: &str = "60";
114
115pub const CONTROL_MASTER_ENV: &str = "MJ_SSH_CONTROL_MASTER";
118
119#[cfg(unix)]
122const MAX_CONTROL_PATH: usize = 103;
123
124#[cfg(unix)]
127const CONNECTION_HASH_HEX: usize = 16;
128
129#[cfg(unix)]
134const CONTROL_SOCKET_NAME_RESERVE: usize = 1 + CONNECTION_HASH_HEX + 1 + 4 + 17;
135
136#[cfg(unix)]
137fn sharing_disabled(value: Option<&std::ffi::OsStr>) -> bool {
138 let Some(value) = value else {
139 return false;
140 };
141 matches!(
142 value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
143 "0" | "off" | "false" | "no"
144 )
145}
146
147#[doc(hidden)]
150#[derive(Debug, Clone)]
151pub enum SshSharingForTest {
152 Disabled,
154 Directory(PathBuf),
156}
157
158static SHARING_OVERRIDE: Mutex<Option<SshSharingForTest>> = Mutex::new(None);
159
160#[cfg(all(test, unix))]
163pub(super) static SHARING_TEST_LOCK: Mutex<()> = Mutex::new(());
164
165#[doc(hidden)]
170pub fn set_ssh_connection_sharing_for_test(setting: Option<SshSharingForTest>) {
171 *SHARING_OVERRIDE
172 .lock()
173 .unwrap_or_else(std::sync::PoisonError::into_inner) = setting;
174}
175
176#[cfg(unix)]
177fn sharing_override() -> Option<SshSharingForTest> {
178 SHARING_OVERRIDE
179 .lock()
180 .unwrap_or_else(std::sync::PoisonError::into_inner)
181 .clone()
182}
183
184#[cfg(unix)]
195fn control_socket_dir() -> Option<PathBuf> {
196 match sharing_override() {
197 Some(SshSharingForTest::Disabled) => return None,
198 Some(SshSharingForTest::Directory(dir)) => return prepare_control_dir(dir),
199 None => {}
200 }
201 static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
202 DIR.get_or_init(|| {
203 if sharing_disabled(std::env::var_os(CONTROL_MASTER_ENV).as_deref()) {
204 return None;
205 }
206 prepare_control_dir(default_control_dir(
207 std::env::var_os("XDG_RUNTIME_DIR"),
208 crate::config::instance_name(),
209 ))
210 })
211 .clone()
212}
213
214#[cfg(unix)]
216fn default_control_dir(runtime: Option<std::ffi::OsString>, instance: Option<String>) -> PathBuf {
217 match runtime {
218 Some(runtime) if !runtime.is_empty() => PathBuf::from(runtime)
219 .join("mjolnir")
220 .join(instance.as_deref().unwrap_or("default")),
221 _ => crate::config::data_dir().join("ssh"),
223 }
224}
225
226#[cfg(unix)]
229fn prepare_control_dir(dir: PathBuf) -> Option<PathBuf> {
230 if dir.as_os_str().len() + CONTROL_SOCKET_NAME_RESERVE > MAX_CONTROL_PATH {
231 tracing::debug!(
232 directory = %dir.display(),
233 "skipping SSH connection sharing: control socket path would be too long"
234 );
235 return None;
236 }
237 if let Err(error) = fs::create_dir_all(&dir) {
238 tracing::debug!(
239 directory = %dir.display(),
240 %error,
241 "skipping SSH connection sharing: control directory is unavailable"
242 );
243 return None;
244 }
245 use std::os::unix::fs::PermissionsExt;
246 if let Err(error) = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)) {
247 tracing::debug!(
248 directory = %dir.display(),
249 %error,
250 "skipping SSH connection sharing: cannot restrict control directory"
251 );
252 return None;
253 }
254 Some(dir)
255}
256
257#[cfg(unix)]
261fn connection_key(ssh: &SshTarget) -> String {
262 let mut key = ssh.destination.clone();
263 for argument in &ssh.ssh_args {
264 key.push('\0');
265 key.push_str(argument);
266 }
267 key
268}
269
270#[cfg(unix)]
276fn control_socket_name(ssh: &SshTarget, shard: usize) -> String {
277 use sha2::{Digest, Sha256};
278 let digest = Sha256::digest(connection_key(ssh).as_bytes());
279 let mut name = String::with_capacity(CONNECTION_HASH_HEX + 5);
280 for byte in digest.iter().take(CONNECTION_HASH_HEX / 2) {
281 name.push_str(&format!("{byte:02x}"));
282 }
283 name.push_str(&format!("-{shard}"));
284 name
285}
286
287#[cfg(unix)]
292fn user_configures_sharing(ssh_args: &[String]) -> bool {
293 ssh_args.iter().any(|argument| {
294 if argument.starts_with("-S") {
295 return true;
296 }
297 let option = argument.strip_prefix("-o").unwrap_or(argument).trim_start();
298 let option = option.to_ascii_lowercase();
299 ["controlmaster", "controlpath"].iter().any(|name| {
300 option
301 .strip_prefix(name)
302 .is_some_and(|rest| rest.starts_with(['=', ' ', '\t']))
303 })
304 })
305}
306
307pub fn push_connection_reuse_args(args: &mut Vec<String>, ssh: &SshTarget) {
325 #[cfg(unix)]
326 if !user_configures_sharing(&ssh.ssh_args)
327 && let Some(dir) = control_socket_dir()
328 {
329 let socket = dir.join(control_socket_name(ssh, 0));
330 args.extend([
331 "-o".to_owned(),
332 "ControlMaster=no".to_owned(),
333 "-o".to_owned(),
334 format!("ControlPath={}", socket.display()),
335 ]);
336 }
337 #[cfg(not(unix))]
338 let _ = (args, ssh);
339}
340
341pub fn join_remote_command(args: &[String]) -> String {
342 args.iter()
343 .map(|arg| posix_quote(arg))
344 .collect::<Vec<_>>()
345 .join(" ")
346}
347
348pub fn ssh_directory_exists(
350 ssh: &SshTarget,
351 path: &Path,
352 executor: &impl CommandExecutor,
353) -> Result<bool> {
354 let command = ssh_validation_command(
355 ssh,
356 vec![
357 "test".into(),
358 "-d".into(),
359 path.to_string_lossy().into_owned(),
360 ],
361 "validate remote directory",
362 );
363 let output = executor.execute(&command)?;
364 match output.status {
365 0 => Ok(true),
366 1 => Ok(false),
367 status => bail!(
368 "remote directory check failed with status {status}: {}",
369 String::from_utf8_lossy(&output.stderr).trim()
370 ),
371 }
372}
373
374pub fn validate_bare_project_directory(
376 ssh: &SshTarget,
377 path: &Path,
378 executor: &impl CommandExecutor,
379) -> Result<()> {
380 validate_bare_project_path(path)?;
381 if !ssh_directory_exists(ssh, path, executor)? {
382 bail!(
383 "remote project directory {} does not exist or is not a directory",
384 path.display()
385 );
386 }
387 let output = executor.execute(&ssh_validation_command(
388 ssh,
389 vec![
390 "git".into(),
391 "-C".into(),
392 path.to_string_lossy().into_owned(),
393 "rev-parse".into(),
394 "--verify".into(),
395 "HEAD".into(),
396 ],
397 "validate bare SSH Git project",
398 ))?;
399 if output.status != 0 {
400 let detail = String::from_utf8_lossy(&output.stderr);
401 let detail = detail.trim();
402 if detail.is_empty() {
403 bail!(
404 "remote project directory {} has no valid Git HEAD",
405 path.display()
406 );
407 }
408 bail!(
409 "remote project directory {} has no valid Git HEAD: {detail}",
410 path.display()
411 );
412 }
413 Ok(())
414}
415
416pub fn validate_bare_project_path(path: &Path) -> Result<()> {
417 if !path.is_absolute()
418 || path
419 .components()
420 .any(|part| part == std::path::Component::ParentDir)
421 {
422 bail!("bare project directory must be an absolute safe path");
423 }
424 Ok(())
425}
426
427pub fn ssh_validation_command(
428 ssh: &SshTarget,
429 remote_args: Vec<String>,
430 purpose: &'static str,
431) -> CommandSpec {
432 let mut args = ssh.ssh_args.clone();
433 args.extend([
434 "-o".into(),
435 "BatchMode=yes".into(),
436 "-o".into(),
437 "ConnectTimeout=3".into(),
438 "-o".into(),
439 "ServerAliveInterval=2".into(),
440 "-o".into(),
441 "ServerAliveCountMax=1".into(),
442 ]);
443 push_connection_reuse_args(&mut args, ssh);
444 args.extend([ssh.destination.clone(), join_remote_command(&remote_args)]);
445 CommandSpec::new("ssh", args)
446 .ssh_destination(ssh.destination.clone())
447 .purpose(purpose)
448}
449
450pub fn posix_quote(value: &str) -> String {
454 format!("'{}'", value.replace('\'', "'\\''"))
455}
456
457pub fn verify_locator(locator: &TargetLocator, session_id: &str) -> Result<()> {
458 let expected_name = resource_name(session_id)?;
459 match locator {
460 TargetLocator::LocalBare { worker_root } => {
461 let path = Path::new(worker_root);
462 if !path.is_absolute()
463 || path
464 .components()
465 .any(|part| part == std::path::Component::ParentDir)
466 || !path.ends_with(session_id)
467 {
468 bail!("refusing cleanup: invalid local bare worker root");
469 }
470 }
471 TargetLocator::LocalPodman {
472 container_id,
473 borrowed_from,
474 ..
475 }
476 | TargetLocator::LocalDocker {
477 container_id,
478 borrowed_from,
479 }
480 | TargetLocator::AppleContainer {
481 container_id,
482 borrowed_from,
483 }
484 | TargetLocator::SshPodman {
485 container_id,
486 borrowed_from,
487 ..
488 }
489 | TargetLocator::SshDocker {
490 container_id,
491 borrowed_from,
492 ..
493 } => match borrowed_from {
494 Some(owner) => {
495 validate_session_id(owner)?;
496 if owner == session_id {
497 bail!(
498 "refusing cleanup: a borrowed container cannot be owned by the borrowing session"
499 );
500 }
501 let owner_name = resource_name(owner)?;
502 if container_id != &owner_name && !is_runtime_container_id(container_id) {
503 bail!(
504 "refusing cleanup: borrowed container locator is neither the owning session's generated name nor an immutable runtime ID"
505 );
506 }
507 }
508 None => {
509 if container_id != &expected_name && !is_runtime_container_id(container_id) {
510 bail!(
511 "refusing cleanup: container locator is neither the generated name nor an immutable runtime ID"
512 );
513 }
514 }
515 },
516 TargetLocator::AwsEc2 {
517 instance_id,
518 workspace,
519 ..
520 } => {
521 if !valid_ec2_instance_id(instance_id) {
522 bail!("refusing cleanup: invalid EC2 instance ID");
523 }
524 verify_session_workspace(workspace, session_id)?;
525 }
526 TargetLocator::SshBare {
527 workspace,
528 worker_id,
529 ..
530 } => match worker_id {
531 Some(worker_id) => {
532 validate_session_id(worker_id)?;
533 if worker_id != session_id {
534 bail!("refusing cleanup: SSH worker identity does not match session ID");
535 }
536 validate_workspace_prefix(workspace)?;
537 }
538 None => verify_session_workspace(workspace, session_id)?,
539 },
540 }
541 Ok(())
542}
543
544pub fn is_borrowed(locator: &TargetLocator) -> bool {
548 match locator {
549 TargetLocator::LocalPodman { borrowed_from, .. }
550 | TargetLocator::LocalDocker { borrowed_from, .. }
551 | TargetLocator::AppleContainer { borrowed_from, .. }
552 | TargetLocator::SshPodman { borrowed_from, .. }
553 | TargetLocator::SshDocker { borrowed_from, .. } => borrowed_from.is_some(),
554 TargetLocator::SshBare { worker_id, .. } => worker_id.is_some(),
555 TargetLocator::LocalBare { .. } | TargetLocator::AwsEc2 { .. } => false,
556 }
557}
558
559pub fn verify_session_workspace(workspace: &str, session_id: &str) -> Result<()> {
560 validate_workspace_prefix(workspace)?;
561 let final_component = workspace.trim_end_matches('/').rsplit('/').next();
562 if final_component != Some(session_id) {
563 bail!("refusing cleanup: workspace does not end in the exact session ID");
564 }
565 Ok(())
566}
567
568pub fn validate_session_id(value: &str) -> Result<()> {
569 if value.len() < 8
570 || value.len() > 128
571 || !value
572 .chars()
573 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
574 {
575 bail!("session ID must be 8-128 ASCII letters, digits, '-' or '_'");
576 }
577 Ok(())
578}
579
580pub fn validate_relative_path(value: &str) -> Result<()> {
581 let path = std::path::Path::new(value);
582 if value.is_empty()
583 || path.is_absolute()
584 || path
585 .components()
586 .any(|part| !matches!(part, std::path::Component::Normal(_)))
587 {
588 bail!("unsafe relative bundle path {value:?}");
589 }
590 Ok(())
591}
592
593pub fn validate_workspace_prefix(value: &str) -> Result<()> {
594 if value.is_empty()
595 || value == "/"
596 || value == "~"
597 || value == "~/"
598 || value.contains('\0')
599 || value.split('/').any(|part| part == "..")
600 {
601 bail!("unsafe workspace path");
602 }
603 Ok(())
604}
605
606pub fn validate_container_template(template: &ContainerTemplate) -> Result<()> {
607 if template.image.trim().is_empty() || template.image.starts_with('-') {
608 bail!("invalid container image");
609 }
610 if template
611 .extra_run_args
612 .iter()
613 .any(|arg| arg == "--name" || arg.starts_with("--name="))
614 {
615 bail!("container template may not override the generated name");
616 }
617 if template.extra_run_args.iter().any(|arg| {
618 arg == "--label"
619 || [SESSION_LABEL, MANAGED_LABEL, INSTANCE_LABEL]
620 .iter()
621 .any(|label| arg.starts_with(&format!("--label={label}=")))
622 }) {
623 bail!("container template may not override Mjolnir ownership labels");
624 }
625 Ok(())
626}
627
628pub fn validate_ssh(ssh: &SshTarget) -> Result<()> {
629 if ssh.destination.trim().is_empty()
630 || ssh.destination.starts_with('-')
631 || ssh.destination.chars().any(char::is_whitespace)
632 {
633 bail!("invalid SSH destination");
634 }
635 Ok(())
636}
637
638pub fn validate_aws(aws: &AwsTemplate) -> Result<()> {
639 validate_ssh(&aws.ssh)?;
640 for (name, value) in [
641 ("AWS profile", &aws.profile),
642 ("AWS region", &aws.region),
643 ("launch template", &aws.launch_template),
644 ] {
645 if value.is_empty()
646 || value.starts_with('-')
647 || !value
648 .chars()
649 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
650 {
651 bail!("invalid {name}");
652 }
653 }
654 Ok(())
655}
656
657pub fn validate_executable(value: &str) -> Result<()> {
658 if value.is_empty() || value.starts_with('-') || value.chars().any(char::is_whitespace) {
659 bail!("invalid executable name");
660 }
661 Ok(())
662}
663
664pub fn valid_ec2_instance_id(value: &str) -> bool {
665 value
666 .strip_prefix("i-")
667 .is_some_and(|rest| rest.len() >= 8 && rest.chars().all(|c| c.is_ascii_hexdigit()))
668}
669
670pub fn is_runtime_container_id(value: &str) -> bool {
671 value.len() >= 12 && value.len() <= 128 && value.chars().all(|c| c.is_ascii_hexdigit())
672}
673
674pub const SSH_TRANSPORT_EXIT_STATUS: i32 = 255;
677
678const TRANSPORT_REJECTION_MARKERS: [&str; 4] = [
682 "Connection closed by",
683 "Connection reset by",
684 "kex_exchange_identification",
685 "Connection timed out during banner exchange",
686];
687
688pub fn is_transport_rejection(status: i32, stderr: &str) -> bool {
694 status == SSH_TRANSPORT_EXIT_STATUS
695 && TRANSPORT_REJECTION_MARKERS
696 .iter()
697 .any(|marker| stderr.contains(marker))
698}
699
700const DEFAULT_MAX_CONCURRENT_SSH: usize = 6;
709
710pub const MAX_CONCURRENT_SSH_ENV: &str = "MJ_SSH_MAX_CONCURRENT";
712
713fn max_concurrent_ssh() -> usize {
714 static LIMIT: OnceLock<usize> = OnceLock::new();
715 *LIMIT.get_or_init(|| positive_env_limit(MAX_CONCURRENT_SSH_ENV, DEFAULT_MAX_CONCURRENT_SSH))
716}
717
718fn positive_env_limit(name: &str, default: usize) -> usize {
721 let Some(raw) = std::env::var_os(name) else {
722 return default;
723 };
724 match raw
725 .to_str()
726 .and_then(|value| value.trim().parse::<usize>().ok())
727 {
728 Some(limit) if limit > 0 => limit,
729 _ => {
730 tracing::warn!(
731 variable = name,
732 value = %raw.to_string_lossy(),
733 default,
734 "ignoring invalid SSH limit"
735 );
736 default
737 }
738 }
739}
740
741struct DestinationGate {
747 limit: usize,
748 in_flight: Mutex<usize>,
749 released: Condvar,
750}
751
752impl DestinationGate {
753 fn new(limit: usize) -> Arc<Self> {
754 Arc::new(Self {
755 limit,
756 in_flight: Mutex::new(0),
757 released: Condvar::new(),
758 })
759 }
760
761 fn acquire(self: &Arc<Self>) -> SshPermit {
762 let mut in_flight = self
763 .in_flight
764 .lock()
765 .unwrap_or_else(std::sync::PoisonError::into_inner);
766 while *in_flight >= self.limit {
767 in_flight = self
768 .released
769 .wait(in_flight)
770 .unwrap_or_else(std::sync::PoisonError::into_inner);
771 }
772 *in_flight += 1;
773 drop(in_flight);
774 SshPermit {
775 gate: Arc::clone(self),
776 }
777 }
778}
779
780pub struct SshPermit {
782 gate: Arc<DestinationGate>,
783}
784
785impl std::fmt::Debug for SshPermit {
786 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
787 formatter.write_str("SshPermit")
788 }
789}
790
791impl Drop for SshPermit {
792 fn drop(&mut self) {
793 let mut in_flight = self
794 .gate
795 .in_flight
796 .lock()
797 .unwrap_or_else(std::sync::PoisonError::into_inner);
798 *in_flight = in_flight.saturating_sub(1);
799 drop(in_flight);
800 self.gate.released.notify_one();
801 }
802}
803
804pub struct SshAdmission;
806
807impl SshAdmission {
808 pub fn acquire(destination: &str) -> SshPermit {
811 Self::gate(destination).acquire()
812 }
813
814 fn gate(destination: &str) -> Arc<DestinationGate> {
815 static GATES: OnceLock<Mutex<BTreeMap<String, Arc<DestinationGate>>>> = OnceLock::new();
816 let mut gates = GATES
817 .get_or_init(|| Mutex::new(BTreeMap::new()))
818 .lock()
819 .unwrap_or_else(std::sync::PoisonError::into_inner);
820 Arc::clone(
821 gates
822 .entry(destination.to_owned())
823 .or_insert_with(|| DestinationGate::new(max_concurrent_ssh())),
824 )
825 }
826}
827
828#[cfg(unix)]
835const DEFAULT_SESSIONS_PER_CONNECTION: usize = 8;
836
837pub const SESSIONS_PER_CONNECTION_ENV: &str = "MJ_SSH_SESSIONS_PER_CONNECTION";
839
840#[cfg(unix)]
843const MASTER_CHECK_INTERVAL: Duration = Duration::from_secs(5);
844
845pub const SSH_MASTER_OPEN_TIMEOUT: Duration = Duration::from_secs(60);
848
849#[cfg(unix)]
850fn sessions_per_connection() -> usize {
851 static LIMIT: OnceLock<usize> = OnceLock::new();
852 *LIMIT.get_or_init(|| {
853 positive_env_limit(SESSIONS_PER_CONNECTION_ENV, DEFAULT_SESSIONS_PER_CONNECTION)
854 })
855}
856
857#[cfg(unix)]
859struct Shard {
860 leased: usize,
861 verified_at: Option<Instant>,
863 opening: Arc<Mutex<()>>,
866}
867
868#[cfg(unix)]
870struct SessionLedger {
871 per_connection: usize,
872 connections: Mutex<BTreeMap<String, Vec<Shard>>>,
873}
874
875#[cfg(unix)]
876impl SessionLedger {
877 fn new(per_connection: usize) -> Arc<Self> {
878 Arc::new(Self {
879 per_connection: per_connection.max(1),
880 connections: Mutex::new(BTreeMap::new()),
881 })
882 }
883
884 fn global() -> Arc<Self> {
885 static LEDGER: OnceLock<Arc<SessionLedger>> = OnceLock::new();
886 Arc::clone(LEDGER.get_or_init(|| Self::new(sessions_per_connection())))
887 }
888
889 fn connections(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Vec<Shard>>> {
890 self.connections
891 .lock()
892 .unwrap_or_else(std::sync::PoisonError::into_inner)
893 }
894
895 fn lease(
898 self: &Arc<Self>,
899 ssh: &SshTarget,
900 dir: &Path,
901 executor: &dyn CommandExecutor,
902 ) -> Result<SshSessionLease> {
903 let key = connection_key(ssh);
904 let (shard, opening) = {
905 let mut connections = self.connections();
906 let shards = connections.entry(key.clone()).or_default();
907 let index = match shards
908 .iter()
909 .position(|shard| shard.leased < self.per_connection)
910 {
911 Some(index) => index,
912 None => {
913 shards.push(Shard {
914 leased: 0,
915 verified_at: None,
916 opening: Arc::new(Mutex::new(())),
917 });
918 shards.len() - 1
919 }
920 };
921 shards[index].leased += 1;
922 (index, Arc::clone(&shards[index].opening))
923 };
924 let slot = LeasedSlot {
926 ledger: Arc::clone(self),
927 key,
928 shard,
929 socket: dir.join(control_socket_name(ssh, shard)),
930 };
931 if slot.needs_check() {
932 let _opening = opening
933 .lock()
934 .unwrap_or_else(std::sync::PoisonError::into_inner);
935 if slot.needs_check() {
938 ensure_master(ssh, &slot.socket, executor)?;
939 slot.set_verified(Some(Instant::now()));
940 }
941 }
942 Ok(SshSessionLease { slot: Some(slot) })
943 }
944}
945
946#[cfg(unix)]
952fn ensure_master(ssh: &SshTarget, socket: &Path, executor: &dyn CommandExecutor) -> Result<()> {
953 if master_running(ssh, socket, executor)? {
954 return Ok(());
955 }
956 match fs::remove_file(socket) {
960 Ok(()) => tracing::debug!(
961 socket = %socket.display(),
962 "removed a stale SSH control socket"
963 ),
964 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
965 Err(error) => {
966 return Err(error)
967 .with_context(|| format!("remove stale SSH control socket {}", socket.display()));
968 }
969 }
970 let opened = executor.execute(&master_open_command(ssh, socket))?;
971 if master_running(ssh, socket, executor)? {
972 tracing::info!(
973 destination = ssh.destination.as_str(),
974 socket = %socket.display(),
975 "opened a shared SSH connection"
976 );
977 return Ok(());
978 }
979 let stderr = String::from_utf8_lossy(&opened.stderr);
980 let detail = match stderr.trim() {
981 "" => format!("ssh exited with status {}", opened.status),
982 stderr => stderr.to_owned(),
983 };
984 bail!(
985 "could not open a shared SSH connection to {}: {detail}",
986 ssh.destination
987 )
988}
989
990#[cfg(unix)]
991fn master_running(ssh: &SshTarget, socket: &Path, executor: &dyn CommandExecutor) -> Result<bool> {
992 Ok(executor.execute(&master_check_command(ssh, socket))?.status == 0)
993}
994
995#[cfg(unix)]
998fn master_check_command(ssh: &SshTarget, socket: &Path) -> CommandSpec {
999 let mut args = ssh.ssh_args.clone();
1000 args.extend([
1001 "-o".to_owned(),
1002 format!("ControlPath={}", socket.display()),
1003 "-O".to_owned(),
1004 "check".to_owned(),
1005 ssh.destination.clone(),
1006 ]);
1007 CommandSpec::new("ssh", args).purpose("check a shared SSH connection")
1008}
1009
1010#[cfg(unix)]
1018fn master_open_command(ssh: &SshTarget, socket: &Path) -> CommandSpec {
1019 let mut args = ssh.ssh_args.clone();
1020 args.extend([
1021 "-o".to_owned(),
1022 "BatchMode=yes".to_owned(),
1023 "-f".to_owned(),
1024 "-N".to_owned(),
1025 "-o".to_owned(),
1026 "ControlMaster=yes".to_owned(),
1027 "-o".to_owned(),
1028 format!("ControlPath={}", socket.display()),
1029 "-o".to_owned(),
1030 format!("ControlPersist={CONTROL_PERSIST}"),
1031 ssh.destination.clone(),
1032 ]);
1033 CommandSpec::new("ssh", args)
1034 .ssh_destination(ssh.destination.clone())
1035 .purpose("open a shared SSH connection")
1036}
1037
1038#[cfg(unix)]
1040struct LeasedSlot {
1041 ledger: Arc<SessionLedger>,
1042 key: String,
1043 shard: usize,
1044 socket: PathBuf,
1045}
1046
1047#[cfg(unix)]
1048impl LeasedSlot {
1049 fn needs_check(&self) -> bool {
1050 let connections = self.ledger.connections();
1051 connections
1052 .get(&self.key)
1053 .and_then(|shards| shards.get(self.shard))
1054 .is_none_or(|shard| {
1055 shard
1056 .verified_at
1057 .is_none_or(|verified| verified.elapsed() >= MASTER_CHECK_INTERVAL)
1058 })
1059 }
1060
1061 fn set_verified(&self, verified_at: Option<Instant>) {
1062 let mut connections = self.ledger.connections();
1063 if let Some(shard) = connections
1064 .get_mut(&self.key)
1065 .and_then(|shards| shards.get_mut(self.shard))
1066 {
1067 shard.verified_at = verified_at;
1068 }
1069 }
1070}
1071
1072#[cfg(unix)]
1073impl Drop for LeasedSlot {
1074 fn drop(&mut self) {
1075 let mut connections = self.ledger.connections();
1076 if let Some(shard) = connections
1077 .get_mut(&self.key)
1078 .and_then(|shards| shards.get_mut(self.shard))
1079 {
1080 shard.leased = shard.leased.saturating_sub(1);
1081 }
1082 }
1083}
1084
1085pub struct SshSessionLease {
1093 #[cfg(unix)]
1094 slot: Option<LeasedSlot>,
1095}
1096
1097impl std::fmt::Debug for SshSessionLease {
1098 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1099 formatter
1100 .debug_struct("SshSessionLease")
1101 .field("control_path", &self.control_path())
1102 .finish()
1103 }
1104}
1105
1106impl SshSessionLease {
1107 fn unshared() -> Self {
1108 Self {
1109 #[cfg(unix)]
1110 slot: None,
1111 }
1112 }
1113
1114 pub fn control_path(&self) -> Option<&Path> {
1117 #[cfg(unix)]
1118 return self.slot.as_ref().map(|slot| slot.socket.as_path());
1119 #[cfg(not(unix))]
1120 None
1121 }
1122
1123 pub fn invalidate(&self) {
1127 #[cfg(unix)]
1128 if let Some(slot) = &self.slot {
1129 slot.set_verified(None);
1130 }
1131 }
1132}
1133
1134pub struct SshSessions;
1141
1142impl SshSessions {
1143 pub fn lease(ssh: &SshTarget, executor: &dyn CommandExecutor) -> Result<SshSessionLease> {
1148 #[cfg(unix)]
1149 {
1150 if user_configures_sharing(&ssh.ssh_args) {
1151 return Ok(SshSessionLease::unshared());
1152 }
1153 let Some(dir) = control_socket_dir() else {
1154 return Ok(SshSessionLease::unshared());
1155 };
1156 SessionLedger::global().lease(ssh, &dir, executor)
1157 }
1158 #[cfg(not(unix))]
1159 {
1160 let _ = (ssh, executor);
1161 Ok(SshSessionLease::unshared())
1162 }
1163 }
1164}
1165
1166pub fn push_session_args(args: &mut Vec<String>, lease: &SshSessionLease) {
1172 if let Some(socket) = lease.control_path() {
1173 args.extend([
1174 "-o".to_owned(),
1175 "ControlMaster=no".to_owned(),
1176 "-o".to_owned(),
1177 format!("ControlPath={}", socket.display()),
1178 "-o".to_owned(),
1179 "ProxyCommand=false".to_owned(),
1180 ]);
1181 }
1182}
1183
1184pub fn session_command_args(
1197 program: &str,
1198 args: &[String],
1199 ssh: &SshTarget,
1200 lease: &SshSessionLease,
1201) -> Vec<String> {
1202 let mut session = Vec::with_capacity(args.len() + 6);
1203 push_session_args(&mut session, lease);
1204 if session.is_empty() {
1205 return args.to_vec();
1206 }
1207 if program == "ssh" && args.starts_with(&ssh.ssh_args) {
1208 let (user, rest) = args.split_at(ssh.ssh_args.len());
1209 let mut user = user.iter();
1210 while let Some(argument) = user.next() {
1211 match argument.strip_prefix("-J") {
1212 Some("") => match user.next() {
1213 Some(jump) => session.extend(["-o".to_owned(), format!("ProxyJump={jump}")]),
1214 None => session.push(argument.clone()),
1215 },
1216 Some(jump) => session.extend(["-o".to_owned(), format!("ProxyJump={jump}")]),
1217 None => session.push(argument.clone()),
1218 }
1219 }
1220 session.extend(rest.iter().cloned());
1221 } else {
1222 session.extend(args.iter().cloned());
1223 }
1224 session
1225}
1226
1227pub const SSH_RETRY_ATTEMPTS: usize = 3;
1229
1230const SSH_RETRY_BACKOFF_MS: [(u64, u64); SSH_RETRY_ATTEMPTS - 1] = [(500, 2_000), (2_000, 4_000)];
1234
1235static SSH_RETRY_BACKOFF_OVERRIDE_MS: AtomicU64 = AtomicU64::new(u64::MAX);
1238
1239#[doc(hidden)]
1242pub fn set_ssh_retry_backoff_for_test(delay: Option<Duration>) {
1243 SSH_RETRY_BACKOFF_OVERRIDE_MS.store(
1244 delay.map_or(u64::MAX, |delay| delay.as_millis() as u64),
1245 Ordering::Relaxed,
1246 );
1247}
1248
1249pub fn ssh_retry_delay(attempts_made: usize) -> Duration {
1255 let override_ms = SSH_RETRY_BACKOFF_OVERRIDE_MS.load(Ordering::Relaxed);
1256 if override_ms != u64::MAX {
1257 return Duration::from_millis(override_ms);
1258 }
1259 let (low, high) = SSH_RETRY_BACKOFF_MS
1260 .get(attempts_made.saturating_sub(1))
1261 .copied()
1262 .unwrap_or(*SSH_RETRY_BACKOFF_MS.last().expect("non-empty schedule"));
1263 let mut bytes = [0_u8; 8];
1264 let spread = if getrandom::fill(&mut bytes).is_ok() {
1266 u64::from_le_bytes(bytes) % (high - low + 1)
1267 } else {
1268 0
1269 };
1270 Duration::from_millis(low + spread)
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275 use super::*;
1276
1277 const BORROW_PARENT: &str = "0123456789abcdef0123456789abcdef";
1278 const BORROW_CHILD: &str = "fedcba9876543210fedcba9876543210";
1279
1280 fn borrowed_podman(owner: &str) -> TargetLocator {
1281 TargetLocator::LocalPodman {
1282 container_id: crate::targets::resource_name(owner).unwrap(),
1283 workspace_storage: PodmanWorkspaceLocator::default(),
1284 borrowed_from: Some(owner.to_owned()),
1285 }
1286 }
1287
1288 #[test]
1289 fn verify_locator_accepts_a_container_borrowed_from_its_owner() {
1290 verify_locator(&borrowed_podman(BORROW_PARENT), BORROW_CHILD)
1291 .expect("a child may borrow its parent's container");
1292 }
1293
1294 #[test]
1295 fn verify_locator_rejects_a_container_borrowed_from_the_checking_session() {
1296 let error = verify_locator(&borrowed_podman(BORROW_PARENT), BORROW_PARENT)
1297 .expect_err("a session cannot borrow from itself");
1298 assert!(
1299 format!("{error:#}").contains("cannot be owned by the borrowing session"),
1300 "unexpected error: {error:#}"
1301 );
1302 }
1303
1304 #[test]
1305 fn verify_locator_rejects_a_borrowed_container_naming_another_session() {
1306 let locator = TargetLocator::LocalPodman {
1307 container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
1308 workspace_storage: PodmanWorkspaceLocator::default(),
1309 borrowed_from: Some(BORROW_PARENT.to_owned()),
1310 };
1311 let error = verify_locator(&locator, BORROW_CHILD)
1312 .expect_err("the container must belong to the recorded owner");
1313 assert!(
1314 format!("{error:#}").contains("borrowed container locator"),
1315 "unexpected error: {error:#}"
1316 );
1317 }
1318
1319 #[test]
1320 fn worker_root_of_a_borrowed_container_is_the_childs_own_directory() {
1321 assert_eq!(
1322 crate::targets::worker_root(&borrowed_podman(BORROW_PARENT), BORROW_CHILD).unwrap(),
1323 format!("/var/lib/hel/workers/{BORROW_CHILD}")
1324 );
1325 }
1326
1327 #[test]
1328 fn is_borrowed_distinguishes_borrowed_targets_from_owned_ones() {
1329 assert!(is_borrowed(&borrowed_podman(BORROW_PARENT)));
1330 assert!(is_borrowed(&TargetLocator::SshBare {
1331 ssh: SshTarget {
1332 destination: "host".to_owned(),
1333 ssh_args: Vec::new(),
1334 },
1335 workspace: format!(".local/share/hel/workspaces/{BORROW_PARENT}"),
1336 worker_id: Some(BORROW_CHILD.to_owned()),
1337 }));
1338 assert!(!is_borrowed(&TargetLocator::LocalPodman {
1339 container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
1340 workspace_storage: PodmanWorkspaceLocator::default(),
1341 borrowed_from: None,
1342 }));
1343 }
1344
1345 #[test]
1346 fn an_owned_container_locator_serializes_without_a_borrowed_from_key() {
1347 let owned = TargetLocator::LocalDocker {
1348 container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
1349 borrowed_from: None,
1350 };
1351 let serialized = serde_json::to_string(&owned).unwrap();
1352 assert!(
1353 !serialized.contains("borrowed_from"),
1354 "owned locators must stay byte-identical for older readers: {serialized}"
1355 );
1356 assert_eq!(
1357 serde_json::from_str::<TargetLocator>(&serialized).unwrap(),
1358 owned
1359 );
1360
1361 let borrowed = borrowed_podman(BORROW_PARENT);
1362 let serialized = serde_json::to_string(&borrowed).unwrap();
1363 assert!(serialized.contains("borrowed_from"));
1364 assert_eq!(
1365 serde_json::from_str::<TargetLocator>(&serialized).unwrap(),
1366 borrowed
1367 );
1368 }
1369 use std::sync::atomic::{AtomicUsize, Ordering};
1370
1371 #[cfg(unix)]
1373 #[derive(Default)]
1374 struct RecordingExecutor {
1375 seen: std::cell::RefCell<Vec<CommandSpec>>,
1376 }
1377
1378 #[cfg(unix)]
1379 impl CommandExecutor for RecordingExecutor {
1380 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1381 self.seen.borrow_mut().push(command.clone());
1382 Ok(CommandOutput {
1383 status: 0,
1384 stdout: Vec::new(),
1385 stderr: Vec::new(),
1386 })
1387 }
1388 }
1389
1390 #[cfg(unix)]
1391 fn sharing_socket_dir() -> tempfile::TempDir {
1392 tempfile::tempdir_in("/tmp").expect("short control socket directory")
1394 }
1395
1396 #[cfg(unix)]
1400 fn spawned_args(
1401 command: &CommandSpec,
1402 dir: Option<&Path>,
1403 masters: &FakeMasters,
1404 ) -> Vec<String> {
1405 set_ssh_connection_sharing_for_test(Some(match dir {
1406 Some(dir) => SshSharingForTest::Directory(dir.to_path_buf()),
1407 None => SshSharingForTest::Disabled,
1408 }));
1409 let session = command.open_ssh_session(masters);
1410 set_ssh_connection_sharing_for_test(None);
1411 session.expect("session").command().args.clone()
1412 }
1413
1414 #[test]
1418 #[cfg(unix)]
1419 fn session_options_lead_the_spawned_command() {
1420 let _guard = SHARING_TEST_LOCK
1421 .lock()
1422 .unwrap_or_else(std::sync::PoisonError::into_inner);
1423 let socket_dir = sharing_socket_dir();
1424 let ssh = SshTarget {
1425 destination: "session-options-host".to_owned(),
1426 ssh_args: vec![
1427 "-p".to_owned(),
1428 "2222".to_owned(),
1429 "-o".to_owned(),
1430 "ProxyCommand=nc %h %p".to_owned(),
1431 ],
1432 };
1433 let command = ssh_command(&ssh, ["true"]);
1434 assert_eq!(
1435 command.args,
1436 [
1437 "-p",
1438 "2222",
1439 "-o",
1440 "ProxyCommand=nc %h %p",
1441 "session-options-host",
1442 "'true'"
1443 ],
1444 "stored arguments never contain sharing options"
1445 );
1446 assert_eq!(command.ssh_session.as_ref(), Some(&ssh));
1447 assert_eq!(
1448 command.ssh_destination.as_deref(),
1449 Some("session-options-host")
1450 );
1451
1452 let masters = FakeMasters::default();
1453 let args = spawned_args(&command, Some(socket_dir.path()), &masters);
1454 let socket = socket_dir.path().join(control_socket_name(&ssh, 0));
1455 assert_eq!(
1456 args,
1457 [
1458 "-o".to_owned(),
1459 "ControlMaster=no".to_owned(),
1460 "-o".to_owned(),
1461 format!("ControlPath={}", socket.display()),
1462 "-o".to_owned(),
1463 "ProxyCommand=false".to_owned(),
1464 "-p".to_owned(),
1465 "2222".to_owned(),
1466 "-o".to_owned(),
1467 "ProxyCommand=nc %h %p".to_owned(),
1468 "session-options-host".to_owned(),
1469 "'true'".to_owned(),
1470 ]
1471 );
1472 assert_eq!(masters.openers(), 1);
1473 assert_eq!(
1474 std::os::unix::fs::MetadataExt::mode(
1475 &fs::metadata(socket_dir.path()).expect("socket directory")
1476 ) & 0o777,
1477 0o700
1478 );
1479 }
1480
1481 #[test]
1485 #[cfg(unix)]
1486 fn a_jump_host_flag_becomes_an_option_the_session_guard_overrides() {
1487 let _guard = SHARING_TEST_LOCK
1488 .lock()
1489 .unwrap_or_else(std::sync::PoisonError::into_inner);
1490 let socket_dir = sharing_socket_dir();
1491 let ssh = SshTarget {
1492 destination: "jump-rewrite-host".to_owned(),
1493 ssh_args: vec!["-J".to_owned(), "bastion".to_owned(), "-Jother".to_owned()],
1494 };
1495 let masters = FakeMasters::default();
1496 let args = spawned_args(
1497 &ssh_command(&ssh, ["-J"]),
1498 Some(socket_dir.path()),
1499 &masters,
1500 );
1501 assert_eq!(
1502 args[6..],
1503 [
1504 "-o",
1505 "ProxyJump=bastion",
1506 "-o",
1507 "ProxyJump=other",
1508 "jump-rewrite-host",
1509 "'-J'",
1510 ]
1511 );
1512 let upload = spawned_args(
1513 &scp_upload(&ssh, Path::new("/tmp/file"), "file", false),
1514 Some(socket_dir.path()),
1515 &masters,
1516 );
1517 assert_eq!(
1518 upload[6..],
1519 [
1520 "-J",
1521 "bastion",
1522 "-Jother",
1523 "/tmp/file",
1524 "jump-rewrite-host:file"
1525 ]
1526 );
1527 }
1528
1529 #[test]
1533 #[cfg(unix)]
1534 fn user_configured_sharing_suppresses_mjolnir_sharing() {
1535 let _guard = SHARING_TEST_LOCK
1536 .lock()
1537 .unwrap_or_else(std::sync::PoisonError::into_inner);
1538 let socket_dir = sharing_socket_dir();
1539 let spellings: [&[&str]; 5] = [
1540 &["-o", "ControlMaster=no"],
1541 &["-o", "controlpath /tmp/mine"],
1542 &["-oControlPath=/tmp/mine"],
1543 &["-S", "/tmp/mine"],
1544 &["-S/tmp/mine"],
1545 ];
1546 let masters = FakeMasters::default();
1547 for user in spellings {
1548 let ssh = SshTarget {
1549 destination: "user-sharing-host".to_owned(),
1550 ssh_args: user.iter().map(|arg| (*arg).to_owned()).collect(),
1551 };
1552 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1553 socket_dir.path().to_path_buf(),
1554 )));
1555 let validation = ssh_validation_command(&ssh, vec!["true".to_owned()], "test");
1556 let command = ssh_command(&ssh, ["true"]);
1557 let args = spawned_args(&command, Some(socket_dir.path()), &masters);
1558 assert_eq!(args, command.args, "user args {user:?}");
1559 let socket_dir_text = socket_dir.path().display().to_string();
1560 assert!(
1561 !validation
1562 .args
1563 .iter()
1564 .any(|arg| arg.contains(&socket_dir_text)),
1565 "user args {user:?}: {:?}",
1566 validation.args
1567 );
1568 }
1569 assert_eq!(masters.commands(), 0);
1570 }
1571
1572 #[test]
1575 #[cfg(unix)]
1576 fn control_sockets_live_in_a_directory_per_instance() {
1577 let runtime = Some(std::ffi::OsString::from("/run/user/1000"));
1578 assert_eq!(
1579 default_control_dir(runtime.clone(), Some("hel2".to_owned())),
1580 PathBuf::from("/run/user/1000/mjolnir/hel2")
1581 );
1582 assert_eq!(
1583 default_control_dir(runtime, None),
1584 PathBuf::from("/run/user/1000/mjolnir/default")
1585 );
1586 }
1587
1588 #[test]
1591 #[cfg(unix)]
1592 fn socket_names_identify_the_connection_and_the_shard() {
1593 let plain = SshTarget {
1594 destination: "host".to_owned(),
1595 ssh_args: Vec::new(),
1596 };
1597 let other_port = SshTarget {
1598 destination: "host".to_owned(),
1599 ssh_args: vec!["-p".to_owned(), "2222".to_owned()],
1600 };
1601 let first = control_socket_name(&plain, 0);
1602 let second = control_socket_name(&plain, 1);
1603 assert_eq!(first.len(), CONNECTION_HASH_HEX + 2, "{first}");
1604 assert!(first.ends_with("-0") && second.ends_with("-1"));
1605 assert_eq!(first[..CONNECTION_HASH_HEX], second[..CONNECTION_HASH_HEX]);
1606 assert_ne!(
1607 first[..CONNECTION_HASH_HEX],
1608 control_socket_name(&other_port, 0)[..CONNECTION_HASH_HEX]
1609 );
1610 }
1611
1612 #[test]
1616 #[cfg(unix)]
1617 fn fail_fast_commands_reuse_a_master_without_becoming_one() {
1618 let _guard = SHARING_TEST_LOCK
1619 .lock()
1620 .unwrap_or_else(std::sync::PoisonError::into_inner);
1621 let socket_dir = sharing_socket_dir();
1622 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1623 socket_dir.path().to_path_buf(),
1624 )));
1625 let ssh = SshTarget {
1626 destination: "host".to_owned(),
1627 ssh_args: Vec::new(),
1628 };
1629 let validation = ssh_validation_command(&ssh, vec!["true".to_owned()], "test").args;
1630 let executor = RecordingExecutor::default();
1631 crate::path_completion::ssh_completions(
1632 &ssh,
1633 "/srv/pr",
1634 crate::path_completion::CompletionKind::Directories,
1635 &executor,
1636 )
1637 .expect("completion runs");
1638 let completion = executor.seen.borrow()[0].args.clone();
1639 set_ssh_connection_sharing_for_test(None);
1640
1641 let control_path = format!(
1642 "ControlPath={}/{}",
1643 socket_dir.path().display(),
1644 control_socket_name(&ssh, 0)
1645 );
1646 for args in [&validation, &completion] {
1647 assert!(args.contains(&"ControlMaster=no".to_owned()), "{args:?}");
1648 assert!(args.contains(&control_path), "{args:?}");
1649 assert!(
1650 !args.iter().any(|arg| arg.starts_with("ControlPersist")),
1651 "a fail-fast command must not set how long a master lingers: {args:?}"
1652 );
1653 let master = args
1654 .iter()
1655 .position(|arg| arg == "ControlMaster=no")
1656 .expect("sharing options");
1657 let alive = args
1658 .iter()
1659 .position(|arg| arg == "ServerAliveCountMax=1")
1660 .expect("its own keepalive");
1661 assert!(alive < master, "{args:?}");
1662 assert!(
1663 master
1664 < args
1665 .iter()
1666 .position(|arg| arg == "host")
1667 .expect("destination"),
1668 "{args:?}"
1669 );
1670 }
1671 }
1672
1673 #[test]
1678 #[cfg(unix)]
1679 fn connectivity_probe_joins_a_master_without_becoming_one() {
1680 let _guard = SHARING_TEST_LOCK
1681 .lock()
1682 .unwrap_or_else(std::sync::PoisonError::into_inner);
1683 let socket_dir = sharing_socket_dir();
1684 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1685 socket_dir.path().to_path_buf(),
1686 )));
1687 let ssh = SshTarget {
1688 destination: "host".to_owned(),
1689 ssh_args: Vec::new(),
1690 };
1691 let args = ssh_connectivity_probe(&ssh).args;
1692 set_ssh_connection_sharing_for_test(None);
1693
1694 assert!(args.contains(&"ControlMaster=no".to_owned()), "{args:?}");
1695 assert!(
1696 args.contains(&format!(
1697 "ControlPath={}/{}",
1698 socket_dir.path().display(),
1699 control_socket_name(&ssh, 0)
1700 )),
1701 "the probe must still join an existing master: {args:?}"
1702 );
1703 assert!(
1704 !args.iter().any(|arg| arg.starts_with("ControlPersist")),
1705 "a doctor probe must not set how long a master lingers: {args:?}"
1706 );
1707 let master = args
1708 .iter()
1709 .position(|arg| arg == "ControlMaster=no")
1710 .expect("sharing options");
1711 let strict = args
1712 .iter()
1713 .position(|arg| arg == "StrictHostKeyChecking=yes")
1714 .expect("its own host key policy");
1715 assert!(strict < master, "{args:?}");
1716 }
1717
1718 #[test]
1719 #[cfg(unix)]
1720 fn connection_sharing_is_absent_when_turned_off() {
1721 let _guard = SHARING_TEST_LOCK
1722 .lock()
1723 .unwrap_or_else(std::sync::PoisonError::into_inner);
1724 let ssh = SshTarget {
1725 destination: "sharing-off-host".to_owned(),
1726 ssh_args: Vec::new(),
1727 };
1728 let masters = FakeMasters::default();
1729 let args = spawned_args(&ssh_command(&ssh, ["true"]), None, &masters);
1730 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
1731 let validation = ssh_validation_command(&ssh, vec!["true".to_owned()], "test").args;
1732 set_ssh_connection_sharing_for_test(None);
1733 assert_eq!(args, ["sharing-off-host", "'true'"]);
1734 assert!(!validation.iter().any(|arg| arg.starts_with("Control")));
1735 assert_eq!(masters.commands(), 0);
1736 }
1737
1738 #[test]
1739 #[cfg(unix)]
1740 fn a_control_path_that_cannot_fit_a_socket_address_is_skipped() {
1741 let _guard = SHARING_TEST_LOCK
1742 .lock()
1743 .unwrap_or_else(std::sync::PoisonError::into_inner);
1744 let root = tempfile::tempdir().expect("temp dir");
1745 let long = root.path().join("a".repeat(MAX_CONTROL_PATH));
1746 let ssh = SshTarget {
1747 destination: "long-path-host".to_owned(),
1748 ssh_args: Vec::new(),
1749 };
1750 let masters = FakeMasters::default();
1751 let args = spawned_args(&ssh_command(&ssh, ["true"]), Some(&long), &masters);
1752 assert_eq!(args, ["long-path-host", "'true'"]);
1753 assert_eq!(masters.commands(), 0);
1754 assert!(!long.exists(), "an unusable directory must not be created");
1755 }
1756
1757 #[test]
1758 #[cfg(not(unix))]
1759 fn connection_sharing_is_unix_only() {
1760 let mut args = vec!["-o".to_owned(), "BatchMode=yes".to_owned()];
1761 let ssh = SshTarget {
1762 destination: "host".to_owned(),
1763 ssh_args: Vec::new(),
1764 };
1765 push_connection_reuse_args(&mut args, &ssh);
1766 assert_eq!(args, vec!["-o".to_owned(), "BatchMode=yes".to_owned()]);
1767 }
1768
1769 #[test]
1770 #[cfg(unix)]
1771 fn the_escape_hatch_accepts_the_usual_off_spellings() {
1772 for value in ["0", "off", "FALSE", " no "] {
1773 assert!(
1774 sharing_disabled(Some(std::ffi::OsStr::new(value))),
1775 "{value:?} must disable connection sharing"
1776 );
1777 }
1778 for value in ["1", "auto", "", "yes"] {
1779 assert!(
1780 !sharing_disabled(Some(std::ffi::OsStr::new(value))),
1781 "{value:?} must leave connection sharing on"
1782 );
1783 }
1784 assert!(!sharing_disabled(None));
1785 }
1786
1787 #[test]
1791 #[cfg(unix)]
1792 fn a_leased_session_runs_through_an_opened_master_on_a_real_host() {
1793 let _guard = SHARING_TEST_LOCK
1794 .lock()
1795 .unwrap_or_else(std::sync::PoisonError::into_inner);
1796 let Some(host) = std::env::var_os("MJ_E2E_SSH_HOST") else {
1797 return;
1798 };
1799 let host = host.to_string_lossy().into_owned();
1800 let socket_dir = sharing_socket_dir();
1801 let ssh = SshTarget {
1802 destination: host.clone(),
1803 ssh_args: vec!["-o".to_owned(), "BatchMode=yes".to_owned()],
1804 };
1805 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1806 socket_dir.path().to_path_buf(),
1807 )));
1808 let output = ProcessExecutor.execute(&ssh_command(&ssh, ["true"]));
1809 set_ssh_connection_sharing_for_test(None);
1810 let socket = socket_dir.path().join(control_socket_name(&ssh, 0));
1811 let check = ProcessExecutor
1812 .execute(&master_check_command(&ssh, &socket))
1813 .expect("ssh -O check must run");
1814 let exit = std::process::Command::new("ssh")
1815 .args([
1816 "-O",
1817 "exit",
1818 "-o",
1819 &format!("ControlPath={}", socket.display()),
1820 &host,
1821 ])
1822 .output();
1823 let output = output.expect("ssh must run");
1824 assert_eq!(
1825 output.status,
1826 0,
1827 "ssh {host} true failed: {}",
1828 String::from_utf8_lossy(&output.stderr)
1829 );
1830 assert_eq!(
1831 check.status,
1832 0,
1833 "no master is running: {}",
1834 String::from_utf8_lossy(&check.stderr)
1835 );
1836 drop(exit);
1837 }
1838
1839 #[test]
1844 #[cfg(unix)]
1845 fn sessions_shard_across_masters_on_a_real_host() {
1846 let _guard = SHARING_TEST_LOCK
1847 .lock()
1848 .unwrap_or_else(std::sync::PoisonError::into_inner);
1849 let Some(host) = std::env::var_os("MJ_E2E_SSH_HOST") else {
1850 return;
1851 };
1852 let host = host.to_string_lossy().into_owned();
1853 let socket_dir = sharing_socket_dir();
1854 let ssh = SshTarget {
1855 destination: host.clone(),
1856 ssh_args: vec!["-o".to_owned(), "BatchMode=yes".to_owned()],
1857 };
1858 let ledger = SessionLedger::new(3);
1859 let leases: Vec<SshSessionLease> = (0..7)
1860 .map(|_| {
1861 ledger
1862 .lease(&ssh, socket_dir.path(), &ProcessExecutor)
1863 .expect("lease a session on a real host")
1864 })
1865 .collect();
1866 let sockets: Vec<PathBuf> = (0..4)
1867 .map(|shard| socket_dir.path().join(control_socket_name(&ssh, shard)))
1868 .collect();
1869 let exit_all = || {
1870 for socket in &sockets {
1871 let _ = std::process::Command::new("ssh")
1872 .args([
1873 "-O",
1874 "exit",
1875 "-o",
1876 &format!("ControlPath={}", socket.display()),
1877 &host,
1878 ])
1879 .output();
1880 }
1881 };
1882
1883 let base = ssh_command(&ssh, ["sleep", "2"]);
1885 let children: Vec<std::io::Result<std::process::Output>> = std::thread::scope(|scope| {
1886 let handles: Vec<_> = leases
1887 .iter()
1888 .map(|lease| {
1889 let args = session_command_args(&base.program, &base.args, &ssh, lease);
1890 scope.spawn(move || {
1891 std::process::Command::new("ssh")
1892 .args(args)
1893 .stdin(std::process::Stdio::null())
1894 .output()
1895 })
1896 })
1897 .collect();
1898 handles
1899 .into_iter()
1900 .map(|handle| handle.join().expect("session thread"))
1901 .collect()
1902 });
1903 let running: Vec<bool> = sockets
1904 .iter()
1905 .map(|socket| {
1906 ProcessExecutor
1907 .execute(&master_check_command(&ssh, socket))
1908 .map(|output| output.status == 0)
1909 .unwrap_or(false)
1910 })
1911 .collect();
1912 let orphan = std::process::Command::new("ssh")
1913 .args(session_command_args(
1914 &base.program,
1915 &base.args,
1916 &ssh,
1917 &SshSessionLease {
1918 slot: Some(LeasedSlot {
1919 ledger: Arc::clone(&ledger),
1920 key: connection_key(&ssh),
1921 shard: 9,
1922 socket: socket_dir.path().join(control_socket_name(&ssh, 9)),
1923 }),
1924 },
1925 ))
1926 .stdin(std::process::Stdio::null())
1927 .output();
1928 drop(leases);
1929 exit_all();
1930
1931 let shards: Vec<usize> = leases_per_shard(&ledger, &ssh);
1932 assert_eq!(shards, [0, 0, 0], "every slot is freed on drop");
1933 for (index, output) in children.iter().enumerate() {
1934 let output = output.as_ref().expect("ssh must run");
1935 assert_eq!(
1936 output.status.code(),
1937 Some(0),
1938 "session {index} failed: {}",
1939 String::from_utf8_lossy(&output.stderr)
1940 );
1941 }
1942 assert_eq!(
1943 running,
1944 [true, true, true, false],
1945 "seven sessions at three per master"
1946 );
1947 let orphan = orphan.expect("ssh must run");
1948 assert_eq!(
1949 orphan.status.code(),
1950 Some(255),
1951 "a guarded session with no master must not connect: {}",
1952 String::from_utf8_lossy(&orphan.stderr)
1953 );
1954 }
1955
1956 #[cfg(unix)]
1957 fn leases_per_shard(ledger: &SessionLedger, ssh: &SshTarget) -> Vec<usize> {
1958 ledger
1959 .connections()
1960 .get(&connection_key(ssh))
1961 .map(|shards| shards.iter().map(|shard| shard.leased).collect())
1962 .unwrap_or_default()
1963 }
1964
1965 #[test]
1969 #[cfg(unix)]
1970 fn scp_translates_the_ssh_port_option_and_is_tagged_with_its_destination() {
1971 let _guard = SHARING_TEST_LOCK
1972 .lock()
1973 .unwrap_or_else(std::sync::PoisonError::into_inner);
1974 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
1975 let ssh = SshTarget {
1976 destination: "build@10.0.0.1".into(),
1977 ssh_args: vec!["-p".into(), "2222".into()],
1978 };
1979
1980 let upload = scp_upload(&ssh, Path::new("/tmp/local"), "remote/path", true);
1981 let download = scp_download(&ssh, "remote/archive.zip", "/tmp/local.zip");
1982 set_ssh_connection_sharing_for_test(None);
1983
1984 assert_eq!(
1985 upload.args,
1986 [
1987 "-P",
1988 "2222",
1989 "-r",
1990 "/tmp/local",
1991 "build@10.0.0.1:remote/path"
1992 ]
1993 );
1994 assert_eq!(
1995 download.args,
1996 [
1997 "-P",
1998 "2222",
1999 "build@10.0.0.1:remote/archive.zip",
2000 "/tmp/local.zip"
2001 ]
2002 );
2003 for command in [upload, download] {
2004 assert_eq!(command.program, "scp");
2005 assert_eq!(command.ssh_destination.as_deref(), Some("build@10.0.0.1"));
2006 }
2007 }
2008
2009 #[cfg(unix)]
2013 #[derive(Default)]
2014 struct FakeMasters {
2015 running: std::cell::RefCell<BTreeSet<String>>,
2016 refuse_open: std::cell::Cell<Option<&'static str>>,
2017 socket_existed_at_open: std::cell::RefCell<Vec<bool>>,
2018 seen: std::cell::RefCell<Vec<CommandSpec>>,
2019 }
2020
2021 #[cfg(unix)]
2022 impl FakeMasters {
2023 fn socket(command: &CommandSpec) -> String {
2024 command
2025 .args
2026 .iter()
2027 .find_map(|arg| arg.strip_prefix("ControlPath="))
2028 .expect("every master command names its socket")
2029 .to_owned()
2030 }
2031
2032 fn kill(&self, socket: &Path) {
2033 self.running
2034 .borrow_mut()
2035 .remove(&socket.display().to_string());
2036 }
2037
2038 fn commands(&self) -> usize {
2039 self.seen.borrow().len()
2040 }
2041
2042 fn openers(&self) -> usize {
2043 self.seen
2044 .borrow()
2045 .iter()
2046 .filter(|command| command.args.contains(&"ControlMaster=yes".to_owned()))
2047 .count()
2048 }
2049 }
2050
2051 #[cfg(unix)]
2052 impl CommandExecutor for FakeMasters {
2053 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2054 self.seen.borrow_mut().push(command.clone());
2055 assert_eq!(command.program, "ssh");
2056 let socket = Self::socket(command);
2057 let (status, stderr) = if command.args.windows(2).any(|pair| pair == ["-O", "check"]) {
2058 if self.running.borrow().contains(&socket) {
2059 (0, "")
2060 } else {
2061 (255, "Control socket connect: No such file or directory")
2062 }
2063 } else if command.args.contains(&"ControlMaster=yes".to_owned()) {
2064 self.socket_existed_at_open
2065 .borrow_mut()
2066 .push(Path::new(&socket).exists());
2067 match self.refuse_open.get() {
2068 Some(stderr) => (255, stderr),
2069 None => {
2070 self.running.borrow_mut().insert(socket);
2071 (0, "")
2072 }
2073 }
2074 } else {
2075 panic!("the ledger ran an unexpected command: {command:?}");
2076 };
2077 Ok(CommandOutput {
2078 status,
2079 stdout: Vec::new(),
2080 stderr: stderr.as_bytes().to_vec(),
2081 })
2082 }
2083 }
2084
2085 #[cfg(unix)]
2086 fn shard_of(lease: &SshSessionLease) -> String {
2087 let path = lease.control_path().expect("a shared lease has a socket");
2088 let name = path.file_name().unwrap().to_string_lossy().into_owned();
2089 name.rsplit('-').next().unwrap().to_owned()
2090 }
2091
2092 #[cfg(unix)]
2093 fn plain_target(destination: &str) -> SshTarget {
2094 SshTarget {
2095 destination: destination.to_owned(),
2096 ssh_args: Vec::new(),
2097 }
2098 }
2099
2100 #[test]
2101 #[cfg(unix)]
2102 fn leases_fill_the_lowest_shard_and_open_another_at_the_cap() {
2103 let dir = sharing_socket_dir();
2104 let ledger = SessionLedger::new(2);
2105 let ssh = plain_target("host");
2106 let masters = FakeMasters::default();
2107
2108 let first = ledger.lease(&ssh, dir.path(), &masters).expect("first");
2109 assert_eq!(masters.commands(), 3);
2111 let second = ledger.lease(&ssh, dir.path(), &masters).expect("second");
2112 assert_eq!(
2113 masters.commands(),
2114 3,
2115 "a master verified moments ago is not checked again"
2116 );
2117 let third = ledger.lease(&ssh, dir.path(), &masters).expect("third");
2118 assert_eq!(
2119 [&first, &second, &third].map(shard_of),
2120 ["0", "0", "1"].map(str::to_owned)
2121 );
2122 assert_eq!(masters.openers(), 2, "one master per shard");
2123 assert_eq!(
2124 third.control_path().unwrap(),
2125 dir.path().join(control_socket_name(&ssh, 1))
2126 );
2127
2128 drop(first);
2129 let fourth = ledger.lease(&ssh, dir.path(), &masters).expect("fourth");
2130 assert_eq!(shard_of(&fourth), "0", "a freed slot is reused first");
2131 assert_eq!(masters.openers(), 2);
2132 }
2133
2134 #[test]
2135 #[cfg(unix)]
2136 fn separate_connections_are_counted_separately() {
2137 let dir = sharing_socket_dir();
2138 let ledger = SessionLedger::new(1);
2139 let masters = FakeMasters::default();
2140 let first = ledger
2141 .lease(&plain_target("one"), dir.path(), &masters)
2142 .expect("one");
2143 let second = ledger
2144 .lease(&plain_target("two"), dir.path(), &masters)
2145 .expect("two");
2146 assert_eq!(
2147 [&first, &second].map(shard_of),
2148 ["0", "0"].map(str::to_owned)
2149 );
2150 assert_ne!(first.control_path(), second.control_path());
2151 }
2152
2153 #[test]
2154 #[cfg(unix)]
2155 fn an_invalidated_lease_makes_the_next_lease_reopen_a_dead_master() {
2156 let dir = sharing_socket_dir();
2157 let ledger = SessionLedger::new(8);
2158 let ssh = plain_target("host");
2159 let masters = FakeMasters::default();
2160 let first = ledger.lease(&ssh, dir.path(), &masters).expect("first");
2161 masters.kill(first.control_path().unwrap());
2162
2163 drop(ledger.lease(&ssh, dir.path(), &masters).expect("trusted"));
2165 assert_eq!(masters.openers(), 1);
2166
2167 first.invalidate();
2168 let second = ledger.lease(&ssh, dir.path(), &masters).expect("reopened");
2169 assert_eq!(masters.openers(), 2);
2170 assert_eq!(first.control_path(), second.control_path());
2171 }
2172
2173 #[test]
2174 #[cfg(unix)]
2175 fn a_master_that_cannot_be_opened_is_an_error_naming_the_destination() {
2176 let dir = sharing_socket_dir();
2177 let ledger = SessionLedger::new(1);
2178 let ssh = plain_target("build@10.0.0.1");
2179 let masters = FakeMasters::default();
2180 masters
2181 .refuse_open
2182 .set(Some("Permission denied (publickey)."));
2183
2184 let error = ledger
2185 .lease(&ssh, dir.path(), &masters)
2186 .expect_err("no master means no session");
2187 let message = format!("{error:#}");
2188 assert!(message.contains("build@10.0.0.1"), "{message}");
2189 assert!(message.contains("Permission denied"), "{message}");
2190 assert_eq!(
2191 masters.openers(),
2192 1,
2193 "the opener is not retried by the ledger"
2194 );
2195
2196 masters.refuse_open.set(None);
2199 let lease = ledger.lease(&ssh, dir.path(), &masters).expect("opens");
2200 assert_eq!(shard_of(&lease), "0");
2201 }
2202
2203 #[test]
2204 #[cfg(unix)]
2205 fn a_stale_socket_is_removed_before_the_master_is_opened() {
2206 let dir = sharing_socket_dir();
2207 let ledger = SessionLedger::new(8);
2208 let ssh = plain_target("host");
2209 let socket = dir.path().join(control_socket_name(&ssh, 0));
2210 fs::write(&socket, b"").expect("stale socket stand-in");
2211 let masters = FakeMasters::default();
2212
2213 ledger.lease(&ssh, dir.path(), &masters).expect("opens");
2214
2215 assert_eq!(*masters.socket_existed_at_open.borrow(), [false]);
2216 }
2217
2218 #[test]
2219 #[cfg(unix)]
2220 fn the_opener_is_an_admitted_batch_master_and_the_check_is_local() {
2221 let ssh = SshTarget {
2222 destination: "host".to_owned(),
2223 ssh_args: vec!["-J".to_owned(), "jump".to_owned()],
2224 };
2225 let socket = Path::new("/run/mj/abc-0");
2226 let open = master_open_command(&ssh, socket);
2227 assert_eq!(
2228 open.args,
2229 [
2230 "-J",
2231 "jump",
2232 "-o",
2233 "BatchMode=yes",
2234 "-f",
2235 "-N",
2236 "-o",
2237 "ControlMaster=yes",
2238 "-o",
2239 "ControlPath=/run/mj/abc-0",
2240 "-o",
2241 &format!("ControlPersist={CONTROL_PERSIST}"),
2242 "host",
2243 ]
2244 );
2245 assert_eq!(open.ssh_destination.as_deref(), Some("host"));
2246
2247 let check = master_check_command(&ssh, socket);
2248 assert_eq!(
2249 check.args,
2250 [
2251 "-J",
2252 "jump",
2253 "-o",
2254 "ControlPath=/run/mj/abc-0",
2255 "-O",
2256 "check",
2257 "host"
2258 ]
2259 );
2260 assert_eq!(
2261 check.ssh_destination, None,
2262 "a check opens no connection and takes no admission permit"
2263 );
2264 }
2265
2266 #[test]
2267 #[cfg(unix)]
2268 fn session_args_forbid_a_direct_connection() {
2269 let dir = sharing_socket_dir();
2270 let ledger = SessionLedger::new(8);
2271 let masters = FakeMasters::default();
2272 let lease = ledger
2273 .lease(&plain_target("host"), dir.path(), &masters)
2274 .expect("lease");
2275 let mut args = Vec::new();
2276 push_session_args(&mut args, &lease);
2277 assert_eq!(
2278 args,
2279 [
2280 "-o".to_owned(),
2281 "ControlMaster=no".to_owned(),
2282 "-o".to_owned(),
2283 format!("ControlPath={}", lease.control_path().unwrap().display()),
2284 "-o".to_owned(),
2285 "ProxyCommand=false".to_owned(),
2286 ]
2287 );
2288 }
2289
2290 #[test]
2293 #[cfg(unix)]
2294 fn unshared_connections_lease_without_a_socket() {
2295 let _guard = SHARING_TEST_LOCK
2296 .lock()
2297 .unwrap_or_else(std::sync::PoisonError::into_inner);
2298 let masters = FakeMasters::default();
2299 let dir = sharing_socket_dir();
2300 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2301 dir.path().to_path_buf(),
2302 )));
2303 let user_owned = SshSessions::lease(
2304 &SshTarget {
2305 destination: "unshared-user-host".to_owned(),
2306 ssh_args: vec!["-S".to_owned(), "/tmp/mine".to_owned()],
2307 },
2308 &masters,
2309 );
2310 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
2311 let disabled = SshSessions::lease(&plain_target("unshared-disabled-host"), &masters);
2312 set_ssh_connection_sharing_for_test(None);
2313
2314 for lease in [user_owned, disabled] {
2315 let lease = lease.expect("an unshared lease never fails");
2316 assert_eq!(lease.control_path(), None);
2317 let mut args = Vec::new();
2318 push_session_args(&mut args, &lease);
2319 assert!(args.is_empty());
2320 }
2321 assert_eq!(masters.commands(), 0);
2322 }
2323
2324 #[test]
2325 fn transport_rejection_matches_only_sshd_hangups() {
2326 let cases: [(i32, &str, bool); 7] = [
2327 (255, "Connection closed by 192.168.1.77 port 22", true),
2328 (
2329 255,
2330 "kex_exchange_identification: read: Connection reset by peer",
2331 true,
2332 ),
2333 (255, "ssh: Connection reset by 10.0.0.1 port 22", true),
2334 (255, "Connection timed out during banner exchange", true),
2335 (255, "Permission denied (publickey).", false),
2336 (
2337 255,
2338 "ssh: connect to host h port 22: Connection refused",
2339 false,
2340 ),
2341 (1, "Connection closed by 192.168.1.77 port 22", false),
2342 ];
2343 for (status, stderr, expected) in cases {
2344 assert_eq!(
2345 is_transport_rejection(status, stderr),
2346 expected,
2347 "status {status} stderr {stderr:?}"
2348 );
2349 }
2350 }
2351
2352 #[test]
2353 fn admission_never_admits_more_than_the_limit() {
2354 let gate = DestinationGate::new(2);
2355 let in_flight = Arc::new(AtomicUsize::new(0));
2356 let peak = Arc::new(AtomicUsize::new(0));
2357 let threads: Vec<_> = (0..12)
2358 .map(|_| {
2359 let gate = Arc::clone(&gate);
2360 let in_flight = Arc::clone(&in_flight);
2361 let peak = Arc::clone(&peak);
2362 std::thread::spawn(move || {
2363 for _ in 0..25 {
2364 let permit = gate.acquire();
2365 let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
2366 peak.fetch_max(now, Ordering::SeqCst);
2367 std::thread::yield_now();
2368 in_flight.fetch_sub(1, Ordering::SeqCst);
2369 drop(permit);
2370 }
2371 })
2372 })
2373 .collect();
2374 for thread in threads {
2375 thread.join().expect("admission worker must not panic");
2376 }
2377 assert!(
2378 peak.load(Ordering::SeqCst) <= 2,
2379 "admission let {} connections run against a 2-permit gate",
2380 peak.load(Ordering::SeqCst)
2381 );
2382 assert_eq!(in_flight.load(Ordering::SeqCst), 0);
2383 }
2384
2385 #[test]
2386 fn admission_blocks_once_every_permit_is_held() {
2387 let gate = DestinationGate::new(2);
2388 let first = gate.acquire();
2389 let second = gate.acquire();
2390 let waiter = {
2391 let gate = Arc::clone(&gate);
2392 std::thread::spawn(move || {
2393 let permit = gate.acquire();
2394 drop(permit);
2395 })
2396 };
2397 std::thread::sleep(std::time::Duration::from_millis(50));
2399 assert!(!waiter.is_finished());
2400 drop(first);
2401 waiter
2402 .join()
2403 .expect("waiter must be admitted once a permit frees");
2404 drop(second);
2405 }
2406}