Skip to main content

mj_core/targets/
ssh.rs

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
9/// The connectivity probe `mj doctor` runs against an SSH target.
10///
11/// It reuses the provisioning argument order so the probe fails exactly where
12/// a real session would, with two deliberate overrides prepended. OpenSSH
13/// honours the first occurrence of an option, so these win over the
14/// provisioning defaults: `BatchMode=yes` never prompts for a password, and
15/// `StrictHostKeyChecking=yes` never accepts an unknown host key. Doctor
16/// diagnoses; the user decides whether to trust a key.
17///
18/// Those overrides are also why the probe joins a shared master but never
19/// opens one (see `push_connection_reuse_args`): as the master it would hold
20/// them over every later session on that connection, and a plain `mj doctor`
21/// would leave an `ssh` process behind for the whole `ControlPersist` window
22/// even though the user asked only for a diagnosis.
23pub fn ssh_connectivity_probe(ssh: &SshTarget) -> CommandSpec {
24    let mut probe = ssh.clone();
25    probe.ssh_args.splice(
26        0..0,
27        [
28            "-o".to_owned(),
29            "BatchMode=yes".to_owned(),
30            "-o".to_owned(),
31            "StrictHostKeyChecking=yes".to_owned(),
32        ],
33    );
34    ssh_command_with_control(&probe, vec!["true".to_owned()], false)
35        .purpose("verify SSH connectivity")
36}
37
38pub fn ssh_command(
39    ssh: &SshTarget,
40    args: impl IntoIterator<Item = impl AsRef<str>>,
41) -> CommandSpec {
42    ssh_command_owned(
43        ssh,
44        args.into_iter()
45            .map(|arg| arg.as_ref().to_owned())
46            .collect(),
47    )
48}
49
50pub fn ssh_command_owned(ssh: &SshTarget, remote_args: Vec<String>) -> CommandSpec {
51    ssh_command_with_control(ssh, remote_args, true)
52}
53
54/// Build an `ssh` command, choosing whether it may open the shared master.
55fn ssh_command_with_control(
56    ssh: &SshTarget,
57    remote_args: Vec<String>,
58    may_become_master: bool,
59) -> CommandSpec {
60    let mut args = ssh.ssh_args.clone();
61    push_control_args(&mut args, may_become_master);
62    args.push(ssh.destination.clone());
63    args.push(join_remote_command(&remote_args));
64    CommandSpec::new("ssh", args).ssh_destination(ssh.destination.clone())
65}
66
67/// Home-relative directory on an SSH host where files bound for a remote
68/// container wait before the engine copies them in. Home-relative rather than
69/// `~/`, because `ssh_command` quotes every argument while `scp` expands `~`.
70pub const REMOTE_UPLOAD_STAGING: &str = ".cache/mjolnir/uploads";
71
72/// Upload a local file or directory to the SSH host.
73pub fn scp_upload(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
74    let mut args = scp_args(ssh);
75    if recursive {
76        args.push("-r".into());
77    }
78    args.push(source.to_string_lossy().into_owned());
79    args.push(format!("{}:{remote}", ssh.destination));
80    scp_command(ssh, args)
81}
82
83/// Download a remote file from the SSH host.
84pub fn scp_download(ssh: &SshTarget, remote: &str, local: &str) -> CommandSpec {
85    let mut args = scp_args(ssh);
86    args.push(format!("{}:{remote}", ssh.destination));
87    args.push(local.into());
88    scp_command(ssh, args)
89}
90
91/// The connection's `ssh` arguments rewritten for `scp`, which spells the port
92/// option `-P`; to `scp`, `-p` means "preserve file times". The connection
93/// sharing options follow, as they do for `ssh`.
94fn scp_args(ssh: &SshTarget) -> Vec<String> {
95    let mut args = ssh
96        .ssh_args
97        .iter()
98        .map(|argument| {
99            if argument == "-p" {
100                "-P".to_owned()
101            } else {
102                argument.clone()
103            }
104        })
105        .collect();
106    push_connection_sharing_args(&mut args);
107    args
108}
109
110fn scp_command(ssh: &SshTarget, args: Vec<String>) -> CommandSpec {
111    // `scp` opens its own connection to the same host, so it competes for the
112    // same pre-auth budget and is admitted and retried the same way.
113    CommandSpec::new("scp", args).ssh_destination(ssh.destination.clone())
114}
115
116/// How long a shared master connection stays alive after its last channel
117/// closes. The master is an `ssh` process that outlives the daemon by this
118/// long, so it is kept short enough to be unsurprising and long enough to
119/// cover a whole provision.
120#[cfg(unix)]
121const CONTROL_PERSIST: &str = "60";
122
123/// Environment override that turns connection sharing off. Any of `0`, `off`,
124/// `false`, or `no` disables it.
125pub const CONTROL_MASTER_ENV: &str = "MJ_SSH_CONTROL_MASTER";
126
127/// Longest `ControlPath` that still fits in a Unix socket address. `sun_path`
128/// holds 108 bytes on Linux and 104 on macOS, minus the terminating NUL.
129#[cfg(unix)]
130const MAX_CONTROL_PATH: usize = 103;
131
132/// `%C` expands to a hash of host, port, user, and local host: SHA-1 hex
133/// today, and 64 characters is reserved for it so a longer one still fits.
134#[cfg(unix)]
135const CONTROL_PATH_FILE: &str = "%C";
136
137#[cfg(unix)]
138fn sharing_disabled(value: Option<&std::ffi::OsStr>) -> bool {
139    let Some(value) = value else {
140        return false;
141    };
142    matches!(
143        value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
144        "0" | "off" | "false" | "no"
145    )
146}
147
148/// How a test pins connection sharing instead of letting it resolve from the
149/// environment.
150#[doc(hidden)]
151#[derive(Debug, Clone)]
152pub enum SshSharingForTest {
153    /// Behave as though the escape hatch were set.
154    Disabled,
155    /// Keep the control sockets in this directory.
156    Directory(PathBuf),
157}
158
159static SHARING_OVERRIDE: Mutex<Option<SshSharingForTest>> = Mutex::new(None);
160
161/// Pin connection sharing for a test, or restore the real resolution with
162/// `None`. Tests must not depend on the developer's `$XDG_RUNTIME_DIR` or home
163/// directory, so every test that inspects `ssh` arguments pins it. Not part of
164/// the daemon's behaviour.
165#[doc(hidden)]
166pub fn set_ssh_connection_sharing_for_test(setting: Option<SshSharingForTest>) {
167    *SHARING_OVERRIDE
168        .lock()
169        .unwrap_or_else(std::sync::PoisonError::into_inner) = setting;
170}
171
172#[cfg(unix)]
173fn sharing_override() -> Option<SshSharingForTest> {
174    SHARING_OVERRIDE
175        .lock()
176        .unwrap_or_else(std::sync::PoisonError::into_inner)
177        .clone()
178}
179
180/// Where the ControlMaster sockets live, or `None` when sharing is off.
181///
182/// `$XDG_RUNTIME_DIR` is preferred because it is short, per-user, and on
183/// tmpfs; the data directory is the fallback. Neither is world-writable, and
184/// the directory is created 0700 because `ssh` will not create it itself.
185#[cfg(unix)]
186fn control_socket_path() -> Option<PathBuf> {
187    match sharing_override() {
188        Some(SshSharingForTest::Disabled) => return None,
189        Some(SshSharingForTest::Directory(dir)) => return prepare_control_path(dir),
190        None => {}
191    }
192    static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
193    DIR.get_or_init(|| {
194        if sharing_disabled(std::env::var_os(CONTROL_MASTER_ENV).as_deref()) {
195            return None;
196        }
197        let base = match std::env::var_os("XDG_RUNTIME_DIR") {
198            Some(runtime) if !runtime.is_empty() => PathBuf::from(runtime).join("mjolnir"),
199            _ => crate::config::data_dir().join("ssh"),
200        };
201        prepare_control_path(base)
202    })
203    .clone()
204}
205
206/// Create the socket directory 0700 and reject one whose sockets would not fit
207/// in a Unix socket address. Failure means no sharing, never a failed command.
208#[cfg(unix)]
209fn prepare_control_path(dir: PathBuf) -> Option<PathBuf> {
210    let socket = dir.join(CONTROL_PATH_FILE);
211    // Measure the path ssh actually binds, with 64 characters reserved for
212    // the `%C` expansion.
213    let bound_len = socket.as_os_str().len() - CONTROL_PATH_FILE.len() + 64;
214    if bound_len > MAX_CONTROL_PATH {
215        tracing::debug!(
216            directory = %dir.display(),
217            "skipping SSH connection sharing: control socket path would be too long"
218        );
219        return None;
220    }
221    if let Err(error) = fs::create_dir_all(&dir) {
222        tracing::debug!(
223            directory = %dir.display(),
224            %error,
225            "skipping SSH connection sharing: control directory is unavailable"
226        );
227        return None;
228    }
229    use std::os::unix::fs::PermissionsExt;
230    if let Err(error) = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)) {
231        tracing::debug!(
232            directory = %dir.display(),
233            %error,
234            "skipping SSH connection sharing: cannot restrict control directory"
235        );
236        return None;
237    }
238    Some(socket)
239}
240
241/// Append the options that let this command create and keep a shared master
242/// connection, to a partly built `ssh` or `scp` argument list.
243///
244/// Every Mjolnir invocation against one destination then rides one
245/// authenticated connection instead of paying for its own handshake. Call this
246/// after the caller's own `ssh_args` and before the destination: OpenSSH keeps
247/// the first value it sees for an option, so a user who sets `ControlMaster`
248/// or `ControlPath` in `extra_args` still wins.
249///
250/// Adds nothing on non-unix (Windows OpenSSH has no ControlMaster), when
251/// `MJ_SSH_CONTROL_MASTER` disables it, or when the socket directory cannot be
252/// prepared.
253pub fn push_connection_sharing_args(args: &mut Vec<String>) {
254    push_control_args(args, true);
255}
256
257/// Append the options that let this command *reuse* a shared master without
258/// ever becoming one.
259///
260/// Use this for commands that carry deliberately impatient options, such as
261/// the short `ConnectTimeout` and one-miss `ServerAlive` keepalive of a
262/// validation probe or a Tab completion. Those settings belong to the one
263/// command that asked for them. If such a command opened the master, the
264/// master would enforce them for its whole lifetime and drop every later
265/// multiplexed session -- an upload, a `podman run`, the worker bootstrap --
266/// on a stall of a couple of seconds. With `ControlMaster=no` the command
267/// joins an existing master when one is up and otherwise opens its own direct
268/// connection, keeping its fail-fast options to itself.
269pub fn push_connection_reuse_args(args: &mut Vec<String>) {
270    push_control_args(args, false);
271}
272
273fn push_control_args(args: &mut Vec<String>, may_become_master: bool) {
274    #[cfg(unix)]
275    if let Some(socket) = control_socket_path() {
276        args.extend([
277            "-o".to_owned(),
278            if may_become_master {
279                "ControlMaster=auto".to_owned()
280            } else {
281                "ControlMaster=no".to_owned()
282            },
283            "-o".to_owned(),
284            format!("ControlPath={}", socket.display()),
285        ]);
286        // Only a command that may open the master decides how long it lingers.
287        if may_become_master {
288            args.extend(["-o".to_owned(), format!("ControlPersist={CONTROL_PERSIST}")]);
289        }
290    }
291    #[cfg(not(unix))]
292    let _ = (args, may_become_master);
293}
294
295pub fn join_remote_command(args: &[String]) -> String {
296    args.iter()
297        .map(|arg| posix_quote(arg))
298        .collect::<Vec<_>>()
299        .join(" ")
300}
301
302/// Check whether a directory exists on the configured SSH host.
303pub fn ssh_directory_exists(
304    ssh: &SshTarget,
305    path: &Path,
306    executor: &impl CommandExecutor,
307) -> Result<bool> {
308    let command = ssh_validation_command(
309        ssh,
310        vec![
311            "test".into(),
312            "-d".into(),
313            path.to_string_lossy().into_owned(),
314        ],
315        "validate remote directory",
316    );
317    let output = executor.execute(&command)?;
318    match output.status {
319        0 => Ok(true),
320        1 => Ok(false),
321        status => bail!(
322            "remote directory check failed with status {status}: {}",
323            String::from_utf8_lossy(&output.stderr).trim()
324        ),
325    }
326}
327
328/// Verify that a bare-SSH project path exists and has a committed Git HEAD.
329pub fn validate_bare_project_directory(
330    ssh: &SshTarget,
331    path: &Path,
332    executor: &impl CommandExecutor,
333) -> Result<()> {
334    validate_bare_project_path(path)?;
335    if !ssh_directory_exists(ssh, path, executor)? {
336        bail!(
337            "remote project directory {} does not exist or is not a directory",
338            path.display()
339        );
340    }
341    let output = executor.execute(&ssh_validation_command(
342        ssh,
343        vec![
344            "git".into(),
345            "-C".into(),
346            path.to_string_lossy().into_owned(),
347            "rev-parse".into(),
348            "--verify".into(),
349            "HEAD".into(),
350        ],
351        "validate bare SSH Git project",
352    ))?;
353    if output.status != 0 {
354        let detail = String::from_utf8_lossy(&output.stderr);
355        let detail = detail.trim();
356        if detail.is_empty() {
357            bail!(
358                "remote project directory {} has no valid Git HEAD",
359                path.display()
360            );
361        }
362        bail!(
363            "remote project directory {} has no valid Git HEAD: {detail}",
364            path.display()
365        );
366    }
367    Ok(())
368}
369
370pub fn validate_bare_project_path(path: &Path) -> Result<()> {
371    if !path.is_absolute()
372        || path
373            .components()
374            .any(|part| part == std::path::Component::ParentDir)
375    {
376        bail!("bare project directory must be an absolute safe path");
377    }
378    Ok(())
379}
380
381pub fn ssh_validation_command(
382    ssh: &SshTarget,
383    remote_args: Vec<String>,
384    purpose: &'static str,
385) -> CommandSpec {
386    let mut args = ssh.ssh_args.clone();
387    args.extend([
388        "-o".into(),
389        "BatchMode=yes".into(),
390        "-o".into(),
391        "ConnectTimeout=3".into(),
392        "-o".into(),
393        "ServerAliveInterval=2".into(),
394        "-o".into(),
395        "ServerAliveCountMax=1".into(),
396    ]);
397    push_connection_reuse_args(&mut args);
398    args.extend([ssh.destination.clone(), join_remote_command(&remote_args)]);
399    CommandSpec::new("ssh", args)
400        .ssh_destination(ssh.destination.clone())
401        .purpose(purpose)
402}
403
404/// Wrap a value so a POSIX shell reads it as one literal argument. Used at the
405/// SSH boundary here and when Hel rebuilds an agent's terminal command line
406/// (`terminal::shell_line`).
407pub fn posix_quote(value: &str) -> String {
408    format!("'{}'", value.replace('\'', "'\\''"))
409}
410
411pub fn verify_locator(locator: &TargetLocator, session_id: &str) -> Result<()> {
412    let expected_name = resource_name(session_id)?;
413    match locator {
414        TargetLocator::LocalBare { worker_root } => {
415            let path = Path::new(worker_root);
416            if !path.is_absolute()
417                || path
418                    .components()
419                    .any(|part| part == std::path::Component::ParentDir)
420                || !path.ends_with(session_id)
421            {
422                bail!("refusing cleanup: invalid local bare worker root");
423            }
424        }
425        TargetLocator::LocalPodman {
426            container_id,
427            borrowed_from,
428            ..
429        }
430        | TargetLocator::LocalDocker {
431            container_id,
432            borrowed_from,
433        }
434        | TargetLocator::AppleContainer {
435            container_id,
436            borrowed_from,
437        }
438        | TargetLocator::SshPodman {
439            container_id,
440            borrowed_from,
441            ..
442        }
443        | TargetLocator::SshDocker {
444            container_id,
445            borrowed_from,
446            ..
447        } => match borrowed_from {
448            Some(owner) => {
449                validate_session_id(owner)?;
450                if owner == session_id {
451                    bail!(
452                        "refusing cleanup: a borrowed container cannot be owned by the borrowing session"
453                    );
454                }
455                let owner_name = resource_name(owner)?;
456                if container_id != &owner_name && !is_runtime_container_id(container_id) {
457                    bail!(
458                        "refusing cleanup: borrowed container locator is neither the owning session's generated name nor an immutable runtime ID"
459                    );
460                }
461            }
462            None => {
463                if container_id != &expected_name && !is_runtime_container_id(container_id) {
464                    bail!(
465                        "refusing cleanup: container locator is neither the generated name nor an immutable runtime ID"
466                    );
467                }
468            }
469        },
470        TargetLocator::AwsEc2 {
471            instance_id,
472            workspace,
473            ..
474        } => {
475            if !valid_ec2_instance_id(instance_id) {
476                bail!("refusing cleanup: invalid EC2 instance ID");
477            }
478            verify_session_workspace(workspace, session_id)?;
479        }
480        TargetLocator::SshBare {
481            workspace,
482            worker_id,
483            ..
484        } => match worker_id {
485            Some(worker_id) => {
486                validate_session_id(worker_id)?;
487                if worker_id != session_id {
488                    bail!("refusing cleanup: SSH worker identity does not match session ID");
489                }
490                validate_workspace_prefix(workspace)?;
491            }
492            None => verify_session_workspace(workspace, session_id)?,
493        },
494    }
495    Ok(())
496}
497
498/// Whether this locator names a target another session owns: a sub-agent
499/// child either borrowing its parent's container or running as its own worker
500/// inside the parent's SSH workspace.
501pub fn is_borrowed(locator: &TargetLocator) -> bool {
502    match locator {
503        TargetLocator::LocalPodman { borrowed_from, .. }
504        | TargetLocator::LocalDocker { borrowed_from, .. }
505        | TargetLocator::AppleContainer { borrowed_from, .. }
506        | TargetLocator::SshPodman { borrowed_from, .. }
507        | TargetLocator::SshDocker { borrowed_from, .. } => borrowed_from.is_some(),
508        TargetLocator::SshBare { worker_id, .. } => worker_id.is_some(),
509        TargetLocator::LocalBare { .. } | TargetLocator::AwsEc2 { .. } => false,
510    }
511}
512
513pub fn verify_session_workspace(workspace: &str, session_id: &str) -> Result<()> {
514    validate_workspace_prefix(workspace)?;
515    let final_component = workspace.trim_end_matches('/').rsplit('/').next();
516    if final_component != Some(session_id) {
517        bail!("refusing cleanup: workspace does not end in the exact session ID");
518    }
519    Ok(())
520}
521
522pub fn validate_session_id(value: &str) -> Result<()> {
523    if value.len() < 8
524        || value.len() > 128
525        || !value
526            .chars()
527            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
528    {
529        bail!("session ID must be 8-128 ASCII letters, digits, '-' or '_'");
530    }
531    Ok(())
532}
533
534pub fn validate_relative_path(value: &str) -> Result<()> {
535    let path = std::path::Path::new(value);
536    if value.is_empty()
537        || path.is_absolute()
538        || path
539            .components()
540            .any(|part| !matches!(part, std::path::Component::Normal(_)))
541    {
542        bail!("unsafe relative bundle path {value:?}");
543    }
544    Ok(())
545}
546
547pub fn validate_workspace_prefix(value: &str) -> Result<()> {
548    if value.is_empty()
549        || value == "/"
550        || value == "~"
551        || value == "~/"
552        || value.contains('\0')
553        || value.split('/').any(|part| part == "..")
554    {
555        bail!("unsafe workspace path");
556    }
557    Ok(())
558}
559
560pub fn validate_container_template(template: &ContainerTemplate) -> Result<()> {
561    if template.image.trim().is_empty() || template.image.starts_with('-') {
562        bail!("invalid container image");
563    }
564    if template
565        .extra_run_args
566        .iter()
567        .any(|arg| arg == "--name" || arg.starts_with("--name="))
568    {
569        bail!("container template may not override the generated name");
570    }
571    if template.extra_run_args.iter().any(|arg| {
572        arg == "--label"
573            || [SESSION_LABEL, MANAGED_LABEL, INSTANCE_LABEL]
574                .iter()
575                .any(|label| arg.starts_with(&format!("--label={label}=")))
576    }) {
577        bail!("container template may not override Mjolnir ownership labels");
578    }
579    Ok(())
580}
581
582pub fn validate_ssh(ssh: &SshTarget) -> Result<()> {
583    if ssh.destination.trim().is_empty()
584        || ssh.destination.starts_with('-')
585        || ssh.destination.chars().any(char::is_whitespace)
586    {
587        bail!("invalid SSH destination");
588    }
589    Ok(())
590}
591
592pub fn validate_aws(aws: &AwsTemplate) -> Result<()> {
593    validate_ssh(&aws.ssh)?;
594    for (name, value) in [
595        ("AWS profile", &aws.profile),
596        ("AWS region", &aws.region),
597        ("launch template", &aws.launch_template),
598    ] {
599        if value.is_empty()
600            || value.starts_with('-')
601            || !value
602                .chars()
603                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
604        {
605            bail!("invalid {name}");
606        }
607    }
608    Ok(())
609}
610
611pub fn validate_executable(value: &str) -> Result<()> {
612    if value.is_empty() || value.starts_with('-') || value.chars().any(char::is_whitespace) {
613        bail!("invalid executable name");
614    }
615    Ok(())
616}
617
618pub fn valid_ec2_instance_id(value: &str) -> bool {
619    value
620        .strip_prefix("i-")
621        .is_some_and(|rest| rest.len() >= 8 && rest.chars().all(|c| c.is_ascii_hexdigit()))
622}
623
624pub fn is_runtime_container_id(value: &str) -> bool {
625    value.len() >= 12 && value.len() <= 128 && value.chars().all(|c| c.is_ascii_hexdigit())
626}
627
628/// `ssh` reserves exit status 255 for its own transport failures; a remote
629/// command never produces it, so the remote side provably never ran.
630pub const SSH_TRANSPORT_EXIT_STATUS: i32 = 255;
631
632/// Stderr fragments OpenSSH prints when the server hangs up before
633/// authentication. `sshd`'s `MaxStartups` produces exactly these when it drops
634/// an unauthenticated connection, and so does a server that is still starting.
635const TRANSPORT_REJECTION_MARKERS: [&str; 4] = [
636    "Connection closed by",
637    "Connection reset by",
638    "kex_exchange_identification",
639    "Connection timed out during banner exchange",
640];
641
642/// Whether a finished `ssh` process was turned away by the transport rather
643/// than by the remote command.
644///
645/// The remote command never started in this case, so the caller may retry the
646/// whole invocation without worrying about repeating a side effect.
647pub fn is_transport_rejection(status: i32, stderr: &str) -> bool {
648    status == SSH_TRANSPORT_EXIT_STATUS
649        && TRANSPORT_REJECTION_MARKERS
650            .iter()
651            .any(|marker| stderr.contains(marker))
652}
653
654/// Default number of `ssh` processes this daemon will have in flight against
655/// one destination at a time.
656///
657/// `sshd` counts *unauthenticated* connections against `MaxStartups`, whose
658/// stock value is `10:30:100`: from the eleventh concurrent pre-auth connection
659/// it starts dropping them, and past a hundred it drops all of them. A daemon
660/// that spawns one fresh `ssh` per operation reaches that during startup, so it
661/// admits its own connections instead of letting the server refuse them.
662const DEFAULT_MAX_CONCURRENT_SSH: usize = 6;
663
664/// Environment override for [`DEFAULT_MAX_CONCURRENT_SSH`].
665pub const MAX_CONCURRENT_SSH_ENV: &str = "MJ_SSH_MAX_CONCURRENT";
666
667fn max_concurrent_ssh() -> usize {
668    static LIMIT: OnceLock<usize> = OnceLock::new();
669    *LIMIT.get_or_init(|| {
670        let Some(raw) = std::env::var_os(MAX_CONCURRENT_SSH_ENV) else {
671            return DEFAULT_MAX_CONCURRENT_SSH;
672        };
673        match raw
674            .to_str()
675            .and_then(|value| value.trim().parse::<usize>().ok())
676        {
677            Some(limit) if limit > 0 => limit,
678            _ => {
679                tracing::warn!(
680                    variable = MAX_CONCURRENT_SSH_ENV,
681                    value = %raw.to_string_lossy(),
682                    default = DEFAULT_MAX_CONCURRENT_SSH,
683                    "ignoring invalid SSH concurrency limit"
684                );
685                DEFAULT_MAX_CONCURRENT_SSH
686            }
687        }
688    })
689}
690
691/// A counting semaphore per SSH destination.
692///
693/// Deliberately built on `std::sync` rather than a runtime primitive: the
694/// blocking process executors are called from plain threads as well as from
695/// `spawn_blocking`, and both must share one gate.
696struct DestinationGate {
697    limit: usize,
698    in_flight: Mutex<usize>,
699    released: Condvar,
700}
701
702impl DestinationGate {
703    fn new(limit: usize) -> Arc<Self> {
704        Arc::new(Self {
705            limit,
706            in_flight: Mutex::new(0),
707            released: Condvar::new(),
708        })
709    }
710
711    fn acquire(self: &Arc<Self>) -> SshPermit {
712        let mut in_flight = self
713            .in_flight
714            .lock()
715            .unwrap_or_else(std::sync::PoisonError::into_inner);
716        while *in_flight >= self.limit {
717            in_flight = self
718                .released
719                .wait(in_flight)
720                .unwrap_or_else(std::sync::PoisonError::into_inner);
721        }
722        *in_flight += 1;
723        drop(in_flight);
724        SshPermit {
725            gate: Arc::clone(self),
726        }
727    }
728}
729
730/// One admitted `ssh` connection. The slot is returned on drop.
731pub struct SshPermit {
732    gate: Arc<DestinationGate>,
733}
734
735impl std::fmt::Debug for SshPermit {
736    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
737        formatter.write_str("SshPermit")
738    }
739}
740
741impl Drop for SshPermit {
742    fn drop(&mut self) {
743        let mut in_flight = self
744            .gate
745            .in_flight
746            .lock()
747            .unwrap_or_else(std::sync::PoisonError::into_inner);
748        *in_flight = in_flight.saturating_sub(1);
749        drop(in_flight);
750        self.gate.released.notify_one();
751    }
752}
753
754/// Process-wide admission control for outbound `ssh` connections.
755pub struct SshAdmission;
756
757impl SshAdmission {
758    /// Block until this process may open another `ssh` connection to
759    /// `destination`. The returned permit holds the slot until it is dropped.
760    pub fn acquire(destination: &str) -> SshPermit {
761        Self::gate(destination).acquire()
762    }
763
764    fn gate(destination: &str) -> Arc<DestinationGate> {
765        static GATES: OnceLock<Mutex<BTreeMap<String, Arc<DestinationGate>>>> = OnceLock::new();
766        let mut gates = GATES
767            .get_or_init(|| Mutex::new(BTreeMap::new()))
768            .lock()
769            .unwrap_or_else(std::sync::PoisonError::into_inner);
770        Arc::clone(
771            gates
772                .entry(destination.to_owned())
773                .or_insert_with(|| DestinationGate::new(max_concurrent_ssh())),
774        )
775    }
776}
777
778/// How many times a transport-rejected `ssh` invocation is tried in total.
779pub const SSH_RETRY_ATTEMPTS: usize = 3;
780
781/// Inclusive millisecond bounds the jittered delay is drawn from, indexed by
782/// the number of attempts already made. `sshd` sheds load for as long as its
783/// pre-auth queue stays full, so the second wait is a multiple of the first.
784const SSH_RETRY_BACKOFF_MS: [(u64, u64); SSH_RETRY_ATTEMPTS - 1] = [(500, 2_000), (2_000, 4_000)];
785
786/// Test override collapsing every retry delay to this many milliseconds.
787/// `u64::MAX` means "no override".
788static SSH_RETRY_BACKOFF_OVERRIDE_MS: AtomicU64 = AtomicU64::new(u64::MAX);
789
790/// Shorten the retry backoff so tests can drive the retry path without
791/// sleeping for seconds. Not part of the daemon's behaviour.
792#[doc(hidden)]
793pub fn set_ssh_retry_backoff_for_test(delay: Option<Duration>) {
794    SSH_RETRY_BACKOFF_OVERRIDE_MS.store(
795        delay.map_or(u64::MAX, |delay| delay.as_millis() as u64),
796        Ordering::Relaxed,
797    );
798}
799
800/// The jittered wait before retry number `attempts_made + 1`.
801///
802/// Jitter matters more than the mean here: every session's reconnect fails at
803/// the same instant, so an unjittered schedule would simply re-send the whole
804/// burst into the same full queue.
805pub fn ssh_retry_delay(attempts_made: usize) -> Duration {
806    let override_ms = SSH_RETRY_BACKOFF_OVERRIDE_MS.load(Ordering::Relaxed);
807    if override_ms != u64::MAX {
808        return Duration::from_millis(override_ms);
809    }
810    let (low, high) = SSH_RETRY_BACKOFF_MS
811        .get(attempts_made.saturating_sub(1))
812        .copied()
813        .unwrap_or(*SSH_RETRY_BACKOFF_MS.last().expect("non-empty schedule"));
814    let mut bytes = [0_u8; 8];
815    // A failed draw only costs jitter, so fall back to the lower bound.
816    let spread = if getrandom::fill(&mut bytes).is_ok() {
817        u64::from_le_bytes(bytes) % (high - low + 1)
818    } else {
819        0
820    };
821    Duration::from_millis(low + spread)
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827
828    const BORROW_PARENT: &str = "0123456789abcdef0123456789abcdef";
829    const BORROW_CHILD: &str = "fedcba9876543210fedcba9876543210";
830
831    fn borrowed_podman(owner: &str) -> TargetLocator {
832        TargetLocator::LocalPodman {
833            container_id: crate::targets::resource_name(owner).unwrap(),
834            workspace_storage: PodmanWorkspaceLocator::default(),
835            borrowed_from: Some(owner.to_owned()),
836        }
837    }
838
839    #[test]
840    fn verify_locator_accepts_a_container_borrowed_from_its_owner() {
841        verify_locator(&borrowed_podman(BORROW_PARENT), BORROW_CHILD)
842            .expect("a child may borrow its parent's container");
843    }
844
845    #[test]
846    fn verify_locator_rejects_a_container_borrowed_from_the_checking_session() {
847        let error = verify_locator(&borrowed_podman(BORROW_PARENT), BORROW_PARENT)
848            .expect_err("a session cannot borrow from itself");
849        assert!(
850            format!("{error:#}").contains("cannot be owned by the borrowing session"),
851            "unexpected error: {error:#}"
852        );
853    }
854
855    #[test]
856    fn verify_locator_rejects_a_borrowed_container_naming_another_session() {
857        let locator = TargetLocator::LocalPodman {
858            container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
859            workspace_storage: PodmanWorkspaceLocator::default(),
860            borrowed_from: Some(BORROW_PARENT.to_owned()),
861        };
862        let error = verify_locator(&locator, BORROW_CHILD)
863            .expect_err("the container must belong to the recorded owner");
864        assert!(
865            format!("{error:#}").contains("borrowed container locator"),
866            "unexpected error: {error:#}"
867        );
868    }
869
870    #[test]
871    fn worker_root_of_a_borrowed_container_is_the_childs_own_directory() {
872        assert_eq!(
873            crate::targets::worker_root(&borrowed_podman(BORROW_PARENT), BORROW_CHILD).unwrap(),
874            format!("/var/lib/hel/workers/{BORROW_CHILD}")
875        );
876    }
877
878    #[test]
879    fn is_borrowed_distinguishes_borrowed_targets_from_owned_ones() {
880        assert!(is_borrowed(&borrowed_podman(BORROW_PARENT)));
881        assert!(is_borrowed(&TargetLocator::SshBare {
882            ssh: SshTarget {
883                destination: "host".to_owned(),
884                ssh_args: Vec::new(),
885            },
886            workspace: format!(".local/share/hel/workspaces/{BORROW_PARENT}"),
887            worker_id: Some(BORROW_CHILD.to_owned()),
888        }));
889        assert!(!is_borrowed(&TargetLocator::LocalPodman {
890            container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
891            workspace_storage: PodmanWorkspaceLocator::default(),
892            borrowed_from: None,
893        }));
894    }
895
896    #[test]
897    fn an_owned_container_locator_serializes_without_a_borrowed_from_key() {
898        let owned = TargetLocator::LocalDocker {
899            container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
900            borrowed_from: None,
901        };
902        let serialized = serde_json::to_string(&owned).unwrap();
903        assert!(
904            !serialized.contains("borrowed_from"),
905            "owned locators must stay byte-identical for older readers: {serialized}"
906        );
907        assert_eq!(
908            serde_json::from_str::<TargetLocator>(&serialized).unwrap(),
909            owned
910        );
911
912        let borrowed = borrowed_podman(BORROW_PARENT);
913        let serialized = serde_json::to_string(&borrowed).unwrap();
914        assert!(serialized.contains("borrowed_from"));
915        assert_eq!(
916            serde_json::from_str::<TargetLocator>(&serialized).unwrap(),
917            borrowed
918        );
919    }
920    use std::sync::atomic::{AtomicUsize, Ordering};
921
922    /// The connection-sharing override is process-wide, so the tests that set
923    /// it take turns.
924    #[cfg(unix)]
925    static SHARING_TEST_LOCK: Mutex<()> = Mutex::new(());
926
927    /// Records the commands it is handed and reports an empty success.
928    #[cfg(unix)]
929    #[derive(Default)]
930    struct RecordingExecutor {
931        seen: std::cell::RefCell<Vec<CommandSpec>>,
932    }
933
934    #[cfg(unix)]
935    impl CommandExecutor for RecordingExecutor {
936        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
937            self.seen.borrow_mut().push(command.clone());
938            Ok(CommandOutput {
939                status: 0,
940                stdout: Vec::new(),
941                stderr: Vec::new(),
942            })
943        }
944    }
945
946    #[cfg(unix)]
947    fn sharing_args(ssh: &SshTarget) -> Vec<String> {
948        ssh_command(ssh, ["true"]).args
949    }
950
951    #[cfg(unix)]
952    fn sharing_socket_dir() -> tempfile::TempDir {
953        // macOS's default temporary path leaves too little room for SSH's hash.
954        tempfile::tempdir_in("/tmp").expect("short control socket directory")
955    }
956
957    #[test]
958    #[cfg(unix)]
959    fn connection_sharing_follows_user_supplied_ssh_args() {
960        let _guard = SHARING_TEST_LOCK
961            .lock()
962            .unwrap_or_else(std::sync::PoisonError::into_inner);
963        let socket_dir = sharing_socket_dir();
964        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
965            socket_dir.path().to_path_buf(),
966        )));
967        let ssh = SshTarget {
968            destination: "host".to_owned(),
969            ssh_args: vec!["-o".to_owned(), "ControlMaster=no".to_owned()],
970        };
971        let args = sharing_args(&ssh);
972        set_ssh_connection_sharing_for_test(None);
973
974        let expected_path = format!("ControlPath={}/%C", socket_dir.path().display());
975        assert_eq!(
976            args,
977            vec![
978                "-o".to_owned(),
979                "ControlMaster=no".to_owned(),
980                "-o".to_owned(),
981                "ControlMaster=auto".to_owned(),
982                "-o".to_owned(),
983                expected_path,
984                "-o".to_owned(),
985                format!("ControlPersist={CONTROL_PERSIST}"),
986                "host".to_owned(),
987                "'true'".to_owned(),
988            ],
989            "sharing options must come after the user's own args, which OpenSSH prefers"
990        );
991        assert_eq!(
992            std::os::unix::fs::MetadataExt::mode(
993                &fs::metadata(socket_dir.path()).expect("socket directory")
994            ) & 0o777,
995            0o700
996        );
997    }
998
999    /// A command with a two-second keepalive must join a master, never open
1000    /// one: as the master it would impose that keepalive on every later
1001    /// session sharing the connection.
1002    #[test]
1003    #[cfg(unix)]
1004    fn fail_fast_commands_reuse_a_master_without_becoming_one() {
1005        let _guard = SHARING_TEST_LOCK
1006            .lock()
1007            .unwrap_or_else(std::sync::PoisonError::into_inner);
1008        let socket_dir = sharing_socket_dir();
1009        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1010            socket_dir.path().to_path_buf(),
1011        )));
1012        let ssh = SshTarget {
1013            destination: "host".to_owned(),
1014            ssh_args: Vec::new(),
1015        };
1016        let validation = ssh_validation_command(&ssh, vec!["true".to_owned()], "test").args;
1017        let executor = RecordingExecutor::default();
1018        crate::path_completion::ssh_completions(
1019            &ssh,
1020            "/srv/pr",
1021            crate::path_completion::CompletionKind::Directories,
1022            &executor,
1023        )
1024        .expect("completion runs");
1025        let completion = executor.seen.borrow()[0].args.clone();
1026        set_ssh_connection_sharing_for_test(None);
1027
1028        let control_path = format!("ControlPath={}/%C", socket_dir.path().display());
1029        for args in [&validation, &completion] {
1030            assert!(args.contains(&"ControlMaster=no".to_owned()), "{args:?}");
1031            assert!(args.contains(&control_path), "{args:?}");
1032            assert!(
1033                !args.iter().any(|arg| arg.starts_with("ControlPersist")),
1034                "a fail-fast command must not set how long a master lingers: {args:?}"
1035            );
1036            let master = args
1037                .iter()
1038                .position(|arg| arg == "ControlMaster=no")
1039                .expect("sharing options");
1040            let alive = args
1041                .iter()
1042                .position(|arg| arg == "ServerAliveCountMax=1")
1043                .expect("its own keepalive");
1044            assert!(alive < master, "{args:?}");
1045            assert!(
1046                master
1047                    < args
1048                        .iter()
1049                        .position(|arg| arg == "host")
1050                        .expect("destination"),
1051                "{args:?}"
1052            );
1053        }
1054    }
1055
1056    /// `mj doctor` diagnoses and exits. Its connectivity probe must join a
1057    /// master when one is up and otherwise open a plain connection, so a
1058    /// doctor run never leaves a `ControlPersist` master behind, and the
1059    /// probe's own strict overrides never bind a shared connection.
1060    #[test]
1061    #[cfg(unix)]
1062    fn connectivity_probe_joins_a_master_without_becoming_one() {
1063        let _guard = SHARING_TEST_LOCK
1064            .lock()
1065            .unwrap_or_else(std::sync::PoisonError::into_inner);
1066        let socket_dir = sharing_socket_dir();
1067        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1068            socket_dir.path().to_path_buf(),
1069        )));
1070        let ssh = SshTarget {
1071            destination: "host".to_owned(),
1072            ssh_args: Vec::new(),
1073        };
1074        let args = ssh_connectivity_probe(&ssh).args;
1075        set_ssh_connection_sharing_for_test(None);
1076
1077        assert!(args.contains(&"ControlMaster=no".to_owned()), "{args:?}");
1078        assert!(
1079            args.contains(&format!("ControlPath={}/%C", socket_dir.path().display())),
1080            "the probe must still join an existing master: {args:?}"
1081        );
1082        assert!(
1083            !args.iter().any(|arg| arg.starts_with("ControlPersist")),
1084            "a doctor probe must not set how long a master lingers: {args:?}"
1085        );
1086        let master = args
1087            .iter()
1088            .position(|arg| arg == "ControlMaster=no")
1089            .expect("sharing options");
1090        let strict = args
1091            .iter()
1092            .position(|arg| arg == "StrictHostKeyChecking=yes")
1093            .expect("its own host key policy");
1094        assert!(strict < master, "{args:?}");
1095    }
1096
1097    #[test]
1098    #[cfg(unix)]
1099    fn connection_sharing_is_absent_when_turned_off() {
1100        let _guard = SHARING_TEST_LOCK
1101            .lock()
1102            .unwrap_or_else(std::sync::PoisonError::into_inner);
1103        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
1104        let ssh = SshTarget {
1105            destination: "host".to_owned(),
1106            ssh_args: Vec::new(),
1107        };
1108        let args = sharing_args(&ssh);
1109        set_ssh_connection_sharing_for_test(None);
1110        assert_eq!(args, vec!["host".to_owned(), "'true'".to_owned()]);
1111    }
1112
1113    #[test]
1114    #[cfg(unix)]
1115    fn a_control_path_that_cannot_fit_a_socket_address_is_skipped() {
1116        let _guard = SHARING_TEST_LOCK
1117            .lock()
1118            .unwrap_or_else(std::sync::PoisonError::into_inner);
1119        let root = tempfile::tempdir().expect("temp dir");
1120        let long = root.path().join("a".repeat(MAX_CONTROL_PATH));
1121        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(long.clone())));
1122        let ssh = SshTarget {
1123            destination: "host".to_owned(),
1124            ssh_args: Vec::new(),
1125        };
1126        let args = sharing_args(&ssh);
1127        set_ssh_connection_sharing_for_test(None);
1128        assert_eq!(args, vec!["host".to_owned(), "'true'".to_owned()]);
1129        assert!(!long.exists(), "an unusable directory must not be created");
1130    }
1131
1132    #[test]
1133    #[cfg(not(unix))]
1134    fn connection_sharing_is_unix_only() {
1135        let mut args = vec!["-o".to_owned(), "BatchMode=yes".to_owned()];
1136        push_connection_sharing_args(&mut args);
1137        assert_eq!(args, vec!["-o".to_owned(), "BatchMode=yes".to_owned()]);
1138    }
1139
1140    #[test]
1141    #[cfg(unix)]
1142    fn the_escape_hatch_accepts_the_usual_off_spellings() {
1143        for value in ["0", "off", "FALSE", " no "] {
1144            assert!(
1145                sharing_disabled(Some(std::ffi::OsStr::new(value))),
1146                "{value:?} must disable connection sharing"
1147            );
1148        }
1149        for value in ["1", "auto", "", "yes"] {
1150            assert!(
1151                !sharing_disabled(Some(std::ffi::OsStr::new(value))),
1152                "{value:?} must leave connection sharing on"
1153            );
1154        }
1155        assert!(!sharing_disabled(None));
1156    }
1157
1158    /// Against a real host: the first invocation must leave a master behind
1159    /// that `ssh -O check` finds. Set `MJ_E2E_SSH_HOST` to a reachable
1160    /// destination to run it.
1161    #[test]
1162    #[cfg(unix)]
1163    fn sharing_leaves_a_reusable_master_on_a_real_host() {
1164        let _guard = SHARING_TEST_LOCK
1165            .lock()
1166            .unwrap_or_else(std::sync::PoisonError::into_inner);
1167        let Some(host) = std::env::var_os("MJ_E2E_SSH_HOST") else {
1168            return;
1169        };
1170        let host = host.to_string_lossy().into_owned();
1171        let socket_dir = sharing_socket_dir();
1172        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1173            socket_dir.path().to_path_buf(),
1174        )));
1175        let ssh = SshTarget {
1176            destination: host.clone(),
1177            ssh_args: vec!["-o".to_owned(), "BatchMode=yes".to_owned()],
1178        };
1179        let spec = ssh_command(&ssh, ["true"]);
1180        set_ssh_connection_sharing_for_test(None);
1181
1182        let first = std::process::Command::new(&spec.program)
1183            .args(&spec.args)
1184            .status()
1185            .expect("ssh must run");
1186        assert!(first.success(), "ssh {host} true failed");
1187
1188        let control_path = format!("{}/%C", socket_dir.path().display());
1189        let check = std::process::Command::new("ssh")
1190            .args([
1191                "-O",
1192                "check",
1193                "-o",
1194                &format!("ControlPath={control_path}"),
1195                &host,
1196            ])
1197            .output()
1198            .expect("ssh -O check must run");
1199        let exit = std::process::Command::new("ssh")
1200            .args([
1201                "-O",
1202                "exit",
1203                "-o",
1204                &format!("ControlPath={control_path}"),
1205                &host,
1206            ])
1207            .output();
1208        assert!(
1209            check.status.success(),
1210            "no master survived the first connection: {}",
1211            String::from_utf8_lossy(&check.stderr)
1212        );
1213        drop(exit);
1214    }
1215
1216    /// `scp` spells the port `-P`; passing an `ssh` `-p` through would ask it
1217    /// to preserve file times and read the port as a file name. Every `scp`
1218    /// also opens a connection, so it is admitted like `ssh`.
1219    #[test]
1220    #[cfg(unix)]
1221    fn scp_translates_the_ssh_port_option_and_is_tagged_with_its_destination() {
1222        let _guard = SHARING_TEST_LOCK
1223            .lock()
1224            .unwrap_or_else(std::sync::PoisonError::into_inner);
1225        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
1226        let ssh = SshTarget {
1227            destination: "build@10.0.0.1".into(),
1228            ssh_args: vec!["-p".into(), "2222".into()],
1229        };
1230
1231        let upload = scp_upload(&ssh, Path::new("/tmp/local"), "remote/path", true);
1232        let download = scp_download(&ssh, "remote/archive.zip", "/tmp/local.zip");
1233        set_ssh_connection_sharing_for_test(None);
1234
1235        assert_eq!(
1236            upload.args,
1237            [
1238                "-P",
1239                "2222",
1240                "-r",
1241                "/tmp/local",
1242                "build@10.0.0.1:remote/path"
1243            ]
1244        );
1245        assert_eq!(
1246            download.args,
1247            [
1248                "-P",
1249                "2222",
1250                "build@10.0.0.1:remote/archive.zip",
1251                "/tmp/local.zip"
1252            ]
1253        );
1254        for command in [upload, download] {
1255            assert_eq!(command.program, "scp");
1256            assert_eq!(command.ssh_destination.as_deref(), Some("build@10.0.0.1"));
1257        }
1258    }
1259
1260    #[test]
1261    fn transport_rejection_matches_only_sshd_hangups() {
1262        let cases: [(i32, &str, bool); 7] = [
1263            (255, "Connection closed by 192.168.1.77 port 22", true),
1264            (
1265                255,
1266                "kex_exchange_identification: read: Connection reset by peer",
1267                true,
1268            ),
1269            (255, "ssh: Connection reset by 10.0.0.1 port 22", true),
1270            (255, "Connection timed out during banner exchange", true),
1271            (255, "Permission denied (publickey).", false),
1272            (
1273                255,
1274                "ssh: connect to host h port 22: Connection refused",
1275                false,
1276            ),
1277            (1, "Connection closed by 192.168.1.77 port 22", false),
1278        ];
1279        for (status, stderr, expected) in cases {
1280            assert_eq!(
1281                is_transport_rejection(status, stderr),
1282                expected,
1283                "status {status} stderr {stderr:?}"
1284            );
1285        }
1286    }
1287
1288    #[test]
1289    fn admission_never_admits_more_than_the_limit() {
1290        let gate = DestinationGate::new(2);
1291        let in_flight = Arc::new(AtomicUsize::new(0));
1292        let peak = Arc::new(AtomicUsize::new(0));
1293        let threads: Vec<_> = (0..12)
1294            .map(|_| {
1295                let gate = Arc::clone(&gate);
1296                let in_flight = Arc::clone(&in_flight);
1297                let peak = Arc::clone(&peak);
1298                std::thread::spawn(move || {
1299                    for _ in 0..25 {
1300                        let permit = gate.acquire();
1301                        let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1302                        peak.fetch_max(now, Ordering::SeqCst);
1303                        std::thread::yield_now();
1304                        in_flight.fetch_sub(1, Ordering::SeqCst);
1305                        drop(permit);
1306                    }
1307                })
1308            })
1309            .collect();
1310        for thread in threads {
1311            thread.join().expect("admission worker must not panic");
1312        }
1313        assert!(
1314            peak.load(Ordering::SeqCst) <= 2,
1315            "admission let {} connections run against a 2-permit gate",
1316            peak.load(Ordering::SeqCst)
1317        );
1318        assert_eq!(in_flight.load(Ordering::SeqCst), 0);
1319    }
1320
1321    #[test]
1322    fn admission_blocks_once_every_permit_is_held() {
1323        let gate = DestinationGate::new(2);
1324        let first = gate.acquire();
1325        let second = gate.acquire();
1326        let waiter = {
1327            let gate = Arc::clone(&gate);
1328            std::thread::spawn(move || {
1329                let permit = gate.acquire();
1330                drop(permit);
1331            })
1332        };
1333        // The third acquire has nothing to take until a permit comes back.
1334        std::thread::sleep(std::time::Duration::from_millis(50));
1335        assert!(!waiter.is_finished());
1336        drop(first);
1337        waiter
1338            .join()
1339            .expect("waiter must be admitted once a permit frees");
1340        drop(second);
1341    }
1342}