Skip to main content

mj_core/
targets.rs

1//! Declarative execution plans for Hel session targets.
2//!
3//! Plans deliberately contain argv vectors instead of local shell strings.  A
4//! shell is used only at the SSH boundary, where OpenSSH necessarily sends a
5//! command string; every remotely supplied argument is POSIX-quoted there.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::io::{Read, Write};
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::{Duration, Instant};
14
15use anyhow::{Context, Result, bail, ensure};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::config::{HarnessKind, ImagePullPolicy};
20
21mod diagnostics;
22pub use diagnostics::{BlockingOperation, BlockingOperationSnapshot, active_blocking_operations};
23pub mod storage;
24
25pub const SESSION_LABEL: &str = "dev.mj.session";
26pub const MANAGED_LABEL: &str = "dev.mj.managed";
27pub const SESSION_TAG: &str = "dev.mj.session";
28pub const MANAGED_TAG: &str = "dev.mj.managed";
29/// Which Mjolnir instance (named `--instance` or data-directory fingerprint)
30/// created a worker; see `config::instance_identity`.
31pub const INSTANCE_LABEL: &str = "dev.mj.instance";
32pub const INSTANCE_TAG: &str = "dev.mj.instance";
33/// The shared in-container workspace every container session used before
34/// per-session workspaces existed. New sessions record a path under it; see
35/// `container_workspace_root`.
36pub const CONTAINER_WORKSPACE: &str = "/workspace";
37
38/// Whether this machine runs Mjolnir workers itself. Windows is a controller
39/// only: its sessions run on Linux, in a container or on an SSH host, so it
40/// has no local bare target and no local worker to probe a profile with.
41pub const HOST_RUNS_WORKERS: bool = cfg!(unix);
42
43/// The launch phase a command belongs to, reported as launch progress.
44#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
45pub enum ProvisionStage {
46    /// Waiting for a background download of this target's image to finish,
47    /// rather than starting a second download of the same image.
48    PullingImage,
49    Provisioning,
50    Booting,
51    Cloning,
52    Syncing,
53    Restoring,
54    Starting,
55    Installing(HarnessKind),
56    PreparingHarness {
57        harness: HarnessKind,
58        step: String,
59        since_ms: i64,
60    },
61    Compacting,
62    RecoveryCopy,
63    Verifying,
64    Closing,
65    StoppingTarget,
66    RemovingContainer,
67    RemovingStorage,
68    CleaningCache,
69}
70
71impl ProvisionStage {
72    pub fn label(&self) -> String {
73        match self {
74            Self::PullingImage => "Pull image".into(),
75            Self::Provisioning => "Provision".into(),
76            Self::Booting => "Boot".into(),
77            Self::Cloning => "Clone".into(),
78            Self::Syncing => "Sync".into(),
79            Self::Restoring => "Restore".into(),
80            Self::Starting => "Start".into(),
81            Self::Installing(harness) => format!("Installing {}", harness.display_name()),
82            Self::PreparingHarness { harness, step, .. } => {
83                format!("Preparing {}: {step}", harness.display_name())
84            }
85            Self::Compacting => "Compact".into(),
86            Self::RecoveryCopy => "Recovery copy".into(),
87            Self::Verifying => "Verify".into(),
88            Self::Closing => "Shut down agent".into(),
89            Self::StoppingTarget => "Stop target".into(),
90            Self::RemovingContainer => "Remove container".into(),
91            Self::RemovingStorage => "Remove container storage".into(),
92            Self::CleaningCache => "Clean cache".into(),
93        }
94    }
95
96    /// The worker owns the start time for harness preparation. Other stages
97    /// start when the controller observes them.
98    pub fn started_at_epoch_seconds(&self) -> Option<u64> {
99        match self {
100            Self::PreparingHarness { since_ms, .. } => {
101                Some(u64::try_from((*since_ms).max(0) / 1_000).unwrap_or_default())
102            }
103            _ => None,
104        }
105    }
106}
107
108#[derive(Clone, PartialEq, Eq)]
109struct SensitiveCommandInput(Vec<u8>);
110
111impl std::fmt::Debug for SensitiveCommandInput {
112    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        formatter.write_str("<redacted>")
114    }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct CommandSpec {
119    pub program: String,
120    pub args: Vec<String>,
121    #[serde(default)]
122    pub env: BTreeMap<String, String>,
123    /// Replace ambient process variables with the explicitly supplied environment.
124    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
125    pub clear_env: bool,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub cwd: Option<std::path::PathBuf>,
128    pub purpose: String,
129    #[serde(default)]
130    pub stage: Option<ProvisionStage>,
131    /// Commands that share this marker and appear consecutively in a plan's
132    /// command list may run concurrently under
133    /// [`CommandPlan::execute_concurrent`]. Commands without a marker, or
134    /// whose neighbors do not share it, keep running strictly in plan order.
135    #[serde(default)]
136    pub parallel_group: Option<u32>,
137    /// Whether this command brings the session's target into existence. Every
138    /// command after it in a provisioning plan runs against a target that
139    /// already exists, so a later failure owes that target's teardown.
140    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
141    pub creates_target: bool,
142    /// This command deliberately leaves a process running after it exits: it
143    /// starts a detached worker, whose lifetime belongs to the session rather
144    /// than to the launch. The executor still gives the command its own
145    /// process group, and cancellation still signals that group, but the
146    /// successful completion of a launch must not kill what it launched.
147    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
148    pub detaches: bool,
149    /// Let child processes finish if the supervisor cancels this command. The
150    /// caller must ensure children can complete without the command process.
151    /// This is executor-local behavior and is not part of serialized plans.
152    #[serde(skip)]
153    preserve_children_on_cancel: bool,
154    /// The SSH destination this command opens a connection to, when it does.
155    /// Tagged commands pass through [`SshAdmission`] so the daemon never
156    /// exceeds the remote `sshd`'s `MaxStartups` budget, and a transport
157    /// rejection is retried rather than reported as a command failure.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub ssh_destination: Option<String>,
160    /// The shared SSH connection this command runs on as one session, when it
161    /// does. Just before spawning, the executor leases a session on one of the
162    /// connection's masters with [`SshSessions::lease`] and puts the options
163    /// that bind the command to that master in front of its arguments (see
164    /// [`CommandSpec::open_ssh_session`]). The arguments stored here never
165    /// contain them.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub ssh_session: Option<SshTarget>,
168    /// The session is a fail-fast probe: it is counted on a shard but never
169    /// opens a master (see [`CommandSpec::ssh_probe_session`]).
170    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
171    pub ssh_session_probe: bool,
172    /// Input that must reach the child without becoming part of its arguments,
173    /// environment, serialized plan, or debug representation.
174    #[serde(skip)]
175    sensitive_stdin: Option<SensitiveCommandInput>,
176}
177
178impl CommandSpec {
179    pub fn new(
180        program: impl Into<String>,
181        args: impl IntoIterator<Item = impl Into<String>>,
182    ) -> Self {
183        Self {
184            program: program.into(),
185            args: args.into_iter().map(Into::into).collect(),
186            env: BTreeMap::new(),
187            clear_env: false,
188            cwd: None,
189            purpose: String::new(),
190            stage: None,
191            parallel_group: None,
192            creates_target: false,
193            detaches: false,
194            preserve_children_on_cancel: false,
195            ssh_destination: None,
196            ssh_session: None,
197            ssh_session_probe: false,
198            sensitive_stdin: None,
199        }
200    }
201
202    pub fn purpose(mut self, purpose: impl Into<String>) -> Self {
203        self.purpose = purpose.into();
204        self
205    }
206
207    pub fn stage(mut self, stage: ProvisionStage) -> Self {
208        self.stage = Some(stage);
209        self
210    }
211
212    /// Mark this command as eligible to run concurrently with its
213    /// plan-adjacent siblings that share the same group.
214    pub fn parallel_group(mut self, group: u32) -> Self {
215        self.parallel_group = Some(group);
216        self
217    }
218
219    /// Record that this command opens an `ssh` connection to `destination`,
220    /// which is the host (or `user@host`) argument, never an option value.
221    pub fn ssh_destination(mut self, destination: impl Into<String>) -> Self {
222        self.ssh_destination = Some(destination.into());
223        self
224    }
225
226    /// Record that this command is an `ssh` or `scp` invocation that runs as
227    /// one session on `ssh`'s shared connection. It also opens a connection
228    /// to that destination, for admission, when sharing is off.
229    pub fn ssh_session(mut self, ssh: &SshTarget) -> Self {
230        self.ssh_destination = Some(ssh.destination.clone());
231        self.ssh_session = Some(ssh.clone());
232        self
233    }
234
235    /// Record that this command is a fail-fast `ssh` probe, such as a target
236    /// validation, that joins `ssh`'s shared connection without ever opening
237    /// a master. Its short timeouts and keepalives belong to the probe alone:
238    /// a master opened by it would impose them on every later session. The
239    /// probe still uses one of the master's sessions while it runs, so the
240    /// executor counts it in the session ledger like any other session.
241    pub fn ssh_probe_session(mut self, ssh: &SshTarget) -> Self {
242        self = self.ssh_session(ssh);
243        self.ssh_session_probe = true;
244        self
245    }
246
247    /// Lease the SSH session this command asks for and return the command as
248    /// it must be spawned, with the lease that must outlive the child.
249    ///
250    /// A command without a session request is returned unchanged. `executor`
251    /// runs the master check and, when needed, the opener.
252    pub fn open_ssh_session(&self, executor: &dyn CommandExecutor) -> Result<SessionCommand<'_>> {
253        let Some(ssh) = &self.ssh_session else {
254            return Ok(SessionCommand {
255                command: std::borrow::Cow::Borrowed(self),
256                lease: None,
257            });
258        };
259        let _waiting = BlockingOperation::start(
260            &format!(
261                "lease SSH connection to {} for {}",
262                ssh.destination, self.purpose
263            ),
264            "ssh",
265        );
266        let lease = if self.ssh_session_probe {
267            SshSessions::lease_probe(ssh)
268        } else {
269            SshSessions::lease(ssh, executor)?
270        };
271        let mut command = self.clone();
272        command.ssh_session = None;
273        command.ssh_session_probe = false;
274        command.args = session_command_args(&self.program, &self.args, ssh, &lease);
275        Ok(SessionCommand {
276            command: std::borrow::Cow::Owned(command),
277            lease: Some(lease),
278        })
279    }
280
281    /// Mark this command as the one that creates the session's target.
282    pub fn creates_target(mut self) -> Self {
283        self.creates_target = true;
284        self
285    }
286
287    /// Keep child processes running if the supervising executor stops waiting.
288    pub fn preserve_children_on_cancel(mut self) -> Self {
289        self.preserve_children_on_cancel = true;
290        self
291    }
292
293    /// Feed private file content through the shared concurrent pipe handler.
294    /// The bytes stay out of argv, environments, serialization, and Debug.
295    pub fn with_sensitive_stdin(mut self, input: Vec<u8>) -> Self {
296        self.sensitive_stdin = Some(SensitiveCommandInput(input));
297        self
298    }
299}
300
301#[cfg(test)]
302mod podman_exec_tests {
303    use super::*;
304
305    #[test]
306    fn podman_exec_keeps_the_container_configured_user() {
307        assert_eq!(
308            CommandSpec::new("podman", ["exec", "--detach", "session", "true"]).args,
309            ["exec", "--detach", "session", "true"]
310        );
311        assert_eq!(
312            CommandSpec::new("podman", ["exec", "--user", "1000:1000", "session", "id"]).args,
313            ["exec", "--user", "1000:1000", "session", "id"]
314        );
315        assert_eq!(
316            CommandSpec::new("podman", ["exec", "session", "tool", "--user", "42"]).args,
317            ["exec", "session", "tool", "--user", "42"]
318        );
319        assert_eq!(
320            CommandSpec::new("docker", ["exec", "session", "id"]).args,
321            ["exec", "session", "id"]
322        );
323    }
324}
325
326/// A command ready to spawn, together with the SSH session lease it runs on.
327/// Keep this value alive until the child has exited: dropping it frees the
328/// session slot.
329#[derive(Debug)]
330pub struct SessionCommand<'a> {
331    command: std::borrow::Cow<'a, CommandSpec>,
332    lease: Option<SshSessionLease>,
333}
334
335impl SessionCommand<'_> {
336    pub fn command(&self) -> &CommandSpec {
337        &self.command
338    }
339
340    pub fn lease(&self) -> Option<&SshSessionLease> {
341        self.lease.as_ref()
342    }
343
344    pub fn into_parts(self) -> (CommandSpec, Option<SshSessionLease>) {
345        (self.command.into_owned(), self.lease)
346    }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct CommandOutput {
351    pub status: i32,
352    pub stdout: Vec<u8>,
353    pub stderr: Vec<u8>,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum DeploymentCapacityKind {
358    Host,
359    AwsFleet,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct DeploymentCapacityTarget {
364    pub id: String,
365    pub host: String,
366    pub target_ids: Vec<String>,
367    pub kind: DeploymentCapacityKind,
368    pub local: bool,
369    /// Alternative commands for a host, or one command per live AWS instance.
370    /// The local host has none: sysinfo reads its CPU and memory, and its
371    /// storage is measured over `local_storage_paths`.
372    pub probes: Vec<CommandSpec>,
373    /// The paths on this machine whose filesystems local targets write to.
374    /// Empty for every other host, whose probes carry their own paths.
375    pub local_storage_paths: Vec<String>,
376    /// Prevents a partial AWS fleet sample when one live instance cannot be probed yet.
377    pub probe_error: Option<String>,
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct DeploymentCapacityUsage {
382    pub cpu_percent: Option<u8>,
383    pub memory_used_bytes: u64,
384    pub memory_total_bytes: u64,
385    pub logical_cores: u64,
386    pub disk_total_bytes: Option<u64>,
387    /// Free space on the filesystems Mjolnir writes to, per machine probed:
388    /// one for a host, one per instance for a fleet.
389    pub storage: Vec<storage::HostStorageSample>,
390}
391
392/// An additional directory made available to one session.
393///
394/// Containers use isolated mounts. Remote targets may instead receive a
395/// controller-packed snapshot at the destination while retaining this shared
396/// persisted shape.
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(from = "AdditionalMountRepr", into = "AdditionalMountRepr")]
399pub struct AdditionalMount {
400    pub source: PathBuf,
401    pub destination: PathBuf,
402    pub access: MountAccess,
403}
404
405/// How a container sees an attached directory.
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
407#[serde(rename_all = "snake_case")]
408pub enum MountAccess {
409    /// The container can read the directory but not change it.
410    Ro,
411    /// The container writes to a private copy-on-write overlay; the host
412    /// directory never changes.
413    Cow,
414    /// The container writes straight through to the host directory.
415    Rw,
416}
417
418impl MountAccess {
419    pub const ALL: [Self; 3] = [Self::Ro, Self::Cow, Self::Rw];
420
421    pub fn label(self) -> &'static str {
422        match self {
423            Self::Ro => "ro",
424            Self::Cow => "cow",
425            Self::Rw => "rw",
426        }
427    }
428
429    /// The mode this attachment takes where the host filesystem cannot carry
430    /// a copy-on-write overlay. Read-only is the substitute, because the
431    /// alternative would let the container write through to the host
432    /// directory the user asked to keep unchanged.
433    ///
434    /// This is the one rule for an unavailable overlay: the wizard offers the
435    /// same modes [`Self::offered`] lists, and the runtime downgrades a stored
436    /// mount the same way.
437    pub fn without_overlay(self) -> Self {
438        match self {
439            Self::Cow => Self::Ro,
440            kept => kept,
441        }
442    }
443
444    /// The modes an attachment may be given, given whether the host filesystem
445    /// can carry the copy-on-write overlay.
446    pub fn offered(overlay_available: bool) -> Vec<Self> {
447        Self::ALL
448            .into_iter()
449            .filter(|access| overlay_available || access.without_overlay() == *access)
450            .collect()
451    }
452}
453
454/// The persisted shape. Read-only and copy-on-write mounts keep the original
455/// `read_only` boolean alone, so archives and API payloads that older builds
456/// read stay unchanged; only read-write adds `access`, which older builds
457/// cannot represent.
458#[derive(Serialize, Deserialize)]
459#[serde(deny_unknown_fields)]
460struct AdditionalMountRepr {
461    source: PathBuf,
462    destination: PathBuf,
463    #[serde(default)]
464    read_only: bool,
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    access: Option<MountAccess>,
467}
468
469impl From<AdditionalMountRepr> for AdditionalMount {
470    fn from(repr: AdditionalMountRepr) -> Self {
471        let access = repr.access.unwrap_or(if repr.read_only {
472            MountAccess::Ro
473        } else {
474            MountAccess::Cow
475        });
476        Self {
477            source: repr.source,
478            destination: repr.destination,
479            access,
480        }
481    }
482}
483
484impl From<AdditionalMount> for AdditionalMountRepr {
485    fn from(mount: AdditionalMount) -> Self {
486        Self {
487            source: mount.source,
488            destination: mount.destination,
489            read_only: mount.access == MountAccess::Ro,
490            access: (mount.access == MountAccess::Rw).then_some(MountAccess::Rw),
491        }
492    }
493}
494
495/// Why a filesystem cannot host a container target's copy-on-write overlay,
496/// or `None` when it can. Unknown types are allowed: the overlay is the better
497/// mount and only a filesystem known to break it is downgraded.
498///
499/// The names are those `stat -f -c %T` reports, matched case-insensitively.
500pub fn overlay_unsupported_filesystem(filesystem: &str) -> Option<&'static str> {
501    let name = filesystem.trim().to_ascii_lowercase();
502    // FUSE reports the backing driver as `fuse.sshfs`, `fuse.s3fs`, and so on.
503    if name == "fuse" || name == "fuseblk" || name.starts_with("fuse.") {
504        return Some("FUSE filesystem");
505    }
506    match name.as_str() {
507        "nfs" | "nfs4" | "cifs" | "smb2" | "smb3" | "9p" | "v9fs" | "virtiofs" | "ceph"
508        | "lustre" | "afs" | "glusterfs" | "ocfs2" | "gfs" | "gfs2" => Some("network filesystem"),
509        "msdos" | "vfat" | "fat" | "exfat" | "ntfs" | "ntfs3" => Some("no POSIX metadata"),
510        "overlayfs" => Some("overlay stacking limit"),
511        _ => None,
512    }
513}
514
515/// Container destinations cannot use the controller or login user's home.
516pub fn validate_mount_destination(path: &Path) -> Result<()> {
517    ensure!(
518        crate::target_path::is_absolute(path)
519            && !path
520                .components()
521                .any(|part| part == std::path::Component::ParentDir),
522        "additional mount destination must be a safe absolute container path; ~ is not supported"
523    );
524    Ok(())
525}
526
527pub fn validate_additional_mounts(mounts: &[AdditionalMount]) -> Result<()> {
528    let mut destinations = BTreeSet::new();
529    for mount in mounts {
530        if !crate::target_path::is_absolute_on_host_or_target(&mount.source)
531            || mount.source.as_os_str().is_empty()
532        {
533            bail!("additional mount source must be an absolute directory path");
534        }
535        validate_mount_destination(&mount.destination)?;
536        if !destinations.insert(mount.destination.clone()) {
537            bail!(
538                "additional mount destination {:?} is configured more than once",
539                mount.destination
540            );
541        }
542    }
543    Ok(())
544}
545
546/// Choose the editable default destination for an additional host directory.
547pub fn default_mount_destination(source: &Path, existing: &[AdditionalMount]) -> PathBuf {
548    let basename = source
549        .file_name()
550        .filter(|name| !name.is_empty())
551        .unwrap_or_else(|| std::ffi::OsStr::new("mount"));
552    let base = PathBuf::from("/mnt").join(basename);
553    if !existing.iter().any(|mount| mount.destination == base) {
554        return base;
555    }
556    for number in 2.. {
557        let candidate =
558            PathBuf::from("/mnt").join(format!("{}-{number}", basename.to_string_lossy()));
559        if !existing.iter().any(|mount| mount.destination == candidate) {
560            return candidate;
561        }
562    }
563    unreachable!("a finite mount list always has an unused numbered destination")
564}
565
566pub trait CommandExecutor {
567    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput>;
568
569    /// Bound cleanup attempts while preserving supervisor cancellation. Durable
570    /// owners retry interrupted cleanup under the next lifecycle or daemon.
571    fn execute_cleanup(&self, command: &CommandSpec) -> Result<CommandOutput> {
572        self.execute(command)
573    }
574
575    /// Bound one cleanup attempt by `timeout`. Executors with shared
576    /// cancellation continue to honor it, and executor-specific hard limits
577    /// may cap the requested timeout. Executors without per-call deadlines
578    /// retain their normal cleanup behavior.
579    fn execute_cleanup_with_timeout(
580        &self,
581        command: &CommandSpec,
582        _timeout: Duration,
583    ) -> Result<CommandOutput> {
584        self.execute_cleanup(command)
585    }
586
587    /// Whether the operation supervising this executor has requested
588    /// cancellation. Test executors and ordinary process execution are not
589    /// cancellable unless they opt in.
590    fn cancellation_requested(&self) -> bool {
591        false
592    }
593
594    /// Report entry into a lifecycle stage. Callers that cover more than one
595    /// command should hold a [`ProvisionStageGuard`] for the whole operation
596    /// so concurrent stages remain visible between subprocesses.
597    fn stage_started(&self, _stage: ProvisionStage) {}
598
599    /// Report exit from a lifecycle stage previously passed to
600    /// [`Self::stage_started`].
601    fn stage_finished(&self, _stage: ProvisionStage) {}
602
603    /// Report a decision an operation made on the user's behalf. This is not a
604    /// failure: the work continues, and the user is told what changed.
605    fn notify_notice(&self, _notice: &str) {}
606
607    /// Stop the source's children only once destination preparation has succeeded.
608    fn before_move_source_stop(
609        &self,
610    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>> {
611        Box::pin(async { Ok(()) })
612    }
613
614    /// The Move source has been sealed; its destination belongs exclusively
615    /// to the lifecycle until queue admission completes.
616    fn reserve_move_destination(&self) {}
617
618    /// Yield daemon admission for interruptible work recoverable after handoff.
619    fn begin_resumable_work(&self) -> Result<()> {
620        Ok(())
621    }
622    /// Reacquire named control admission before leaving resumable work.
623    fn end_resumable_work(&self, _label: &'static str) -> Result<()> {
624        Ok(())
625    }
626
627    fn execute_with_stdin(
628        &self,
629        _command: &CommandSpec,
630        _input: &mut (dyn Read + Send),
631    ) -> Result<CommandOutput> {
632        bail!("this command executor does not support streamed stdin")
633    }
634}
635
636/// A scoped lifecycle-stage report for controller-side work or a sequence of
637/// commands. Dropping the guard reports completion even when the work returns
638/// early with an error.
639pub struct ProvisionStageGuard<'a, E: CommandExecutor + ?Sized> {
640    executor: &'a E,
641    stage: ProvisionStage,
642}
643
644impl<'a, E: CommandExecutor + ?Sized> ProvisionStageGuard<'a, E> {
645    pub fn new(executor: &'a E, stage: ProvisionStage) -> Self {
646        executor.stage_started(stage.clone());
647        Self { executor, stage }
648    }
649}
650
651impl<E: CommandExecutor + ?Sized> Drop for ProvisionStageGuard<'_, E> {
652    fn drop(&mut self) {
653        self.executor.stage_finished(self.stage.clone());
654    }
655}
656
657pub struct ProcessExecutor;
658
659/// Run one `ssh` invocation under process-wide admission control, retrying it
660/// when the server turned the connection away before authentication.
661///
662/// A permit is held only while a child is actually running and is released
663/// between attempts, because a waiting retry occupies no connection slot. A
664/// transport rejection means the remote command never started, so re-running
665/// the whole invocation cannot repeat a side effect.
666///
667/// A command that asks for a shared-connection session leases it on each
668/// attempt, before taking its permit: opening a master takes a permit of its
669/// own, and waiting for that while holding one could exhaust the gate. A
670/// session turned away by the transport usually means its master died, so
671/// the lease is invalidated and the retry checks and reopens the master.
672///
673/// Commands that are not tagged with a destination run untouched.
674fn with_ssh_admission(
675    command: &CommandSpec,
676    executor: &dyn CommandExecutor,
677    is_cancelled: &dyn Fn() -> bool,
678    mut run: impl FnMut(&CommandSpec) -> Result<CommandOutput>,
679) -> Result<CommandOutput> {
680    let Some(destination) = command.ssh_destination.as_deref() else {
681        return run(command);
682    };
683    for attempt in 1..=SSH_RETRY_ATTEMPTS {
684        let session = command.open_ssh_session(executor)?;
685        let output = {
686            let _permit = SshAdmission::acquire_unless(destination, is_cancelled)?;
687            run(session.command())?
688        };
689        let refusal = ssh_refusal(output.status, &String::from_utf8_lossy(&output.stderr));
690        // A refused session found its master alive; any other refusal may
691        // mean the master is gone.
692        if refusal == Some(SshRefusal::BeforeAuthentication)
693            && let Some(lease) = session.lease()
694        {
695            lease.invalidate();
696        }
697        drop(session);
698        let Some(refusal) = refusal else {
699            return Ok(output);
700        };
701        let stderr = String::from_utf8_lossy(&output.stderr);
702        if attempt == SSH_RETRY_ATTEMPTS {
703            refusal.log_exhausted(destination, &command.purpose, stderr.trim());
704            return Ok(output);
705        }
706        let delay = ssh_retry_delay(attempt);
707        refusal.log_retry(destination, &command.purpose, attempt, delay, stderr.trim());
708        if !sleep_unless_cancelled(delay, is_cancelled) {
709            bail!("operation cancelled while {}", command.purpose);
710        }
711    }
712    unreachable!("the final attempt always returns");
713}
714
715/// Wait out `delay`, giving up early if the supervising operation is
716/// cancelled. Returns whether the wait completed.
717fn sleep_unless_cancelled(delay: Duration, is_cancelled: &dyn Fn() -> bool) -> bool {
718    let deadline = Instant::now() + delay;
719    loop {
720        if is_cancelled() {
721            return false;
722        }
723        let remaining = deadline.saturating_duration_since(Instant::now());
724        if remaining.is_zero() {
725            return true;
726        }
727        std::thread::sleep(remaining.min(Duration::from_millis(50)));
728    }
729}
730
731/// Every process executor finishes a command here: one debug line, so a slow
732/// launch or resume phase can be attributed from logs instead of re-profiled
733/// by hand, and one look at a failure for a full disk on the target.
734fn finished_command(
735    command: &CommandSpec,
736    started: Instant,
737    status: i32,
738    stdout: Vec<u8>,
739    stderr: Vec<u8>,
740) -> CommandOutput {
741    tracing::debug!(
742        purpose = command.purpose.as_str(),
743        program = command.program.as_str(),
744        status,
745        elapsed_ms = started.elapsed().as_millis() as u64,
746        "target command finished"
747    );
748    let output = CommandOutput {
749        status,
750        stdout,
751        stderr,
752    };
753    storage::observe_command_output(command, &output);
754    output
755}
756
757impl ProcessExecutor {
758    /// One attempt, with no admission or retry of its own.
759    fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
760        if let Some(input) = &command.sensitive_stdin {
761            let mut input = std::io::Cursor::new(input.0.as_slice());
762            // Owned bytes, so each attempt gets its own reader.
763            return stream_command_with_stdin(
764                cancellable_command(command),
765                command,
766                &mut input,
767                &|| false,
768            );
769        }
770        let started = Instant::now();
771        let output = configured_command(command)
772            .stdin(Stdio::null())
773            .output()
774            .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
775        let status = output.status.code().unwrap_or(-1);
776        Ok(finished_command(
777            command,
778            started,
779            status,
780            output.stdout,
781            output.stderr,
782        ))
783    }
784}
785
786// Both process executors wait for their child, and for SSH admission before
787// it, through `off_async_worker`: a caller several calls below an `async fn`
788// must not hold one of the runtime's async worker threads for the length of a
789// target command.
790impl CommandExecutor for ProcessExecutor {
791    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
792        crate::runtime::off_async_worker(|| {
793            let _running = BlockingOperation::command(command);
794            with_ssh_admission(command, self, &|| false, |command| self.run_once(command))
795        })
796    }
797
798    fn execute_with_stdin(
799        &self,
800        command: &CommandSpec,
801        input: &mut (dyn Read + Send),
802    ) -> Result<CommandOutput> {
803        crate::runtime::off_async_worker(|| {
804            let _running = BlockingOperation::command(command);
805            // A caller's stream cannot be replayed, so this path takes a
806            // session and a permit but never retries.
807            let session = command.open_ssh_session(self)?;
808            let _permit = command
809                .ssh_destination
810                .as_deref()
811                .map(SshAdmission::acquire);
812            let command = session.command();
813            let process = cancellable_command(command);
814            // Plain process execution is not cancellable, so the transfer only
815            // ends when the child does.
816            stream_command_with_stdin(process, command, input, &|| false)
817        })
818    }
819}
820
821/// Streams `input` into a freshly spawned child and collects its output.
822///
823/// Both executors share this one implementation because the pipe edge cases
824/// below are easy to get subtly wrong in a second copy.
825///
826/// `is_cancelled` reports whether the supervising operation wants the transfer
827/// abandoned; [`ProcessExecutor`] passes a check that is never true, which also
828/// makes the kill path below unreachable for it.
829fn stream_command_with_stdin(
830    mut process: Command,
831    command: &CommandSpec,
832    input: &mut (dyn Read + Send),
833    is_cancelled: &(dyn Fn() -> bool + Sync),
834) -> Result<CommandOutput> {
835    let started = Instant::now();
836    if is_cancelled() {
837        bail!("operation cancelled");
838    }
839    let mut child = process
840        .stdin(Stdio::piped())
841        .stdout(Stdio::piped())
842        .stderr(Stdio::piped())
843        .spawn()
844        .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
845    let group =
846        (!command.detaches).then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
847    let stdin = child
848        .stdin
849        .take()
850        .context("streamed command stdin missing")?;
851    let stdout = child
852        .stdout
853        .take()
854        .context("streamed command stdout missing")?;
855    let stderr = child
856        .stderr
857        .take()
858        .context("streamed command stderr missing")?;
859    // Reader threads keep the child's output pipes drained; a child that fills
860    // one while nobody reads would block instead of exiting.
861    let stdout_reader = PipeCollector::spawn(stdout);
862    let stderr_reader = PipeCollector::spawn(stderr);
863    let process_result = std::thread::scope(|scope| -> Result<_> {
864        // Pipe writes can block forever when a remote helper stops reading.
865        // Keep the writer off the supervising thread so cancellation can kill
866        // the process group and thereby close the blocked pipe.
867        let input_writer = scope.spawn(move || -> Result<()> {
868            // Owning `stdin` here is what closes the pipe's write end once the
869            // transfer finishes. A child that reads to EOF, such as
870            // `mj worker export-checkpoint --spec -`, never exits while any
871            // copy of the write end is still open.
872            let mut stdin = stdin;
873            let mut buffer = [0_u8; 64 * 1024];
874            loop {
875                // Checking before each chunk makes large checkpoint copies
876                // cooperatively cancellable without changing the executor
877                // interface.
878                if is_cancelled() {
879                    bail!("operation cancelled");
880                }
881                let count = input.read(&mut buffer).context("read command input")?;
882                if count == 0 {
883                    break;
884                }
885                stdin
886                    .write_all(&buffer[..count])
887                    .context("stream command input")?;
888            }
889            stdin.flush().context("flush command input")
890        });
891        let mut status = None;
892        let mut exited_at = None;
893        let mut group_killed = false;
894        let status = loop {
895            if is_cancelled() {
896                terminate_cancellable_child(&mut child, false);
897                if let Err(error) = input_writer.join() {
898                    tracing::warn!(
899                        purpose = command.purpose.as_str(),
900                        "streamed command input writer panicked while cancelling: {error:?}"
901                    );
902                }
903                bail!("operation cancelled while {}", command.purpose);
904            }
905            match if status.is_some() {
906                Ok(status)
907            } else {
908                child.try_wait()
909            } {
910                Ok(observed) => status = observed,
911                Err(error) => {
912                    terminate_cancellable_child(&mut child, false);
913                    if let Err(join_error) = input_writer.join() {
914                        tracing::warn!(
915                            purpose = command.purpose.as_str(),
916                            "streamed command input writer panicked while waiting: {join_error:?}"
917                        );
918                    }
919                    return Err(error).with_context(|| format!("wait for {}", command.purpose));
920                }
921            }
922            // The command completes when its leader exits. Descendants can
923            // retain the pipes, so output is collected for a bounded time;
924            // then the owned group is terminated, which also releases a
925            // writer blocked on a pipe a descendant holds.
926            if let Some(status) = status {
927                let exited_at = *exited_at.get_or_insert_with(Instant::now);
928                let drained = (stdout_reader.is_finished() && stderr_reader.is_finished())
929                    || exited_at.elapsed() >= IO_DRAIN_TIMEOUT;
930                if drained && !input_writer.is_finished() && !group_killed {
931                    group_killed = true;
932                    if let Some(group) = &group {
933                        group.kill();
934                    }
935                }
936                if drained && input_writer.is_finished() {
937                    break status;
938                }
939            }
940            std::thread::sleep(Duration::from_millis(25));
941        };
942        let input_result = input_writer
943            .join()
944            .map_err(|_| anyhow::anyhow!("streamed command input writer panicked"))?;
945        Ok((status, input_result))
946    });
947    let deadline = Instant::now();
948    let stdout = stdout_reader.finish("stdout", deadline)?;
949    let stderr = stderr_reader.finish("stderr", deadline)?;
950    let (status, input_result) = process_result?;
951    drop(group);
952    if status.success() {
953        // A child that exited first explains the failure through its own
954        // status and stderr; the broken pipe that exit caused would only hide
955        // it. A successful child must not hide an input error.
956        input_result?;
957    }
958    let status = status.code().unwrap_or(-1);
959    Ok(finished_command(command, started, status, stdout, stderr))
960}
961
962#[derive(Clone)]
963pub struct CancellableProcessExecutor {
964    cancelled: Arc<AtomicBool>,
965    deadline: Option<Instant>,
966}
967
968/// Cancels synchronous executor work when its supervising async wait is dropped.
969pub struct ProcessCancellationGuard(Arc<AtomicBool>);
970
971impl Drop for ProcessCancellationGuard {
972    fn drop(&mut self) {
973        self.0.store(true, Ordering::Release);
974    }
975}
976
977impl CancellableProcessExecutor {
978    pub fn cancel_on_drop(&self) -> ProcessCancellationGuard {
979        ProcessCancellationGuard(self.cancelled.clone())
980    }
981
982    pub fn new(cancelled: Arc<AtomicBool>) -> Self {
983        Self {
984            cancelled,
985            deadline: None,
986        }
987    }
988
989    pub fn is_cancelled(&self) -> bool {
990        self.cancelled.load(Ordering::Acquire)
991            || self
992                .deadline
993                .is_some_and(|deadline| Instant::now() >= deadline)
994    }
995
996    pub fn with_timeout(timeout: Duration) -> Self {
997        Self {
998            cancelled: Arc::new(AtomicBool::new(false)),
999            deadline: Some(Instant::now() + timeout),
1000        }
1001    }
1002
1003    /// Bounds an existing flag-based executor with a deadline, so a wedged
1004    /// child becomes a reported failure instead of running forever.
1005    pub fn with_deadline(mut self, timeout: Duration) -> Self {
1006        self.deadline = Some(Instant::now() + timeout);
1007        self
1008    }
1009
1010    fn check_cancelled(&self) -> Result<()> {
1011        if self.is_cancelled() {
1012            bail!("operation cancelled");
1013        }
1014        Ok(())
1015    }
1016}
1017
1018fn configured_command(command: &CommandSpec) -> Command {
1019    let mut process = Command::new(&command.program);
1020    if command.clear_env {
1021        process.env_clear();
1022    }
1023    if let Some(cwd) = &command.cwd {
1024        process.current_dir(cwd);
1025    }
1026    process.args(&command.args).envs(&command.env);
1027    process
1028}
1029
1030fn cancellable_command(command: &CommandSpec) -> Command {
1031    #[cfg(unix)]
1032    let mut process = configured_command(command);
1033    #[cfg(not(unix))]
1034    let process = configured_command(command);
1035    #[cfg(unix)]
1036    {
1037        use std::os::unix::process::CommandExt as _;
1038        process.process_group(0);
1039    }
1040    process
1041}
1042
1043/// How long a command's output may keep arriving after its leader exits.
1044/// A backgrounded descendant inherits the output pipes and can hold them open
1045/// for its whole life, although the command it was started by is finished.
1046/// The same bound as Codex's `IO_DRAIN_TIMEOUT_MS`. The owned process group is
1047/// terminated once this drain ends.
1048const IO_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
1049
1050/// A child pipe that can be waited on with a timeout, so its reader can be
1051/// stopped while a descendant still holds the write end.
1052trait PollablePipe: Read + Send + 'static {
1053    fn readable(&self, timeout: Duration) -> bool;
1054}
1055
1056macro_rules! pollable_pipe {
1057    ($pipe:ty) => {
1058        impl PollablePipe for $pipe {
1059            #[cfg(unix)]
1060            fn readable(&self, timeout: Duration) -> bool {
1061                use std::os::fd::AsRawFd as _;
1062                let mut poll = libc::pollfd {
1063                    fd: self.as_raw_fd(),
1064                    events: libc::POLLIN,
1065                    revents: 0,
1066                };
1067                let millis = i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX);
1068                // SAFETY: `poll` is one initialized pollfd for an open descriptor.
1069                unsafe { libc::poll(&raw mut poll, 1, millis) > 0 }
1070            }
1071
1072            // Without polling, a read blocks until the pipe closes.
1073            #[cfg(not(unix))]
1074            fn readable(&self, _timeout: Duration) -> bool {
1075                true
1076            }
1077        }
1078    };
1079}
1080
1081pollable_pipe!(std::process::ChildStdout);
1082pollable_pipe!(std::process::ChildStderr);
1083
1084/// Collects one child pipe on its own thread. Stopping it drops the thread's
1085/// end of the pipe, so a descendant that keeps the pipe open cannot hold the
1086/// reader past the drain.
1087struct PipeCollector {
1088    bytes: Arc<std::sync::Mutex<Vec<u8>>>,
1089    stop: Arc<AtomicBool>,
1090    thread: Option<std::thread::JoinHandle<std::io::Result<()>>>,
1091}
1092
1093impl PipeCollector {
1094    fn spawn(mut pipe: impl PollablePipe) -> Self {
1095        let bytes = Arc::new(std::sync::Mutex::new(Vec::new()));
1096        let stop = Arc::new(AtomicBool::new(false));
1097        let (collected, stopped) = (bytes.clone(), stop.clone());
1098        let thread = std::thread::spawn(move || {
1099            let mut chunk = [0_u8; 8192];
1100            while !stopped.load(Ordering::Acquire) {
1101                if !pipe.readable(Duration::from_millis(25)) {
1102                    continue;
1103                }
1104                match pipe.read(&mut chunk) {
1105                    Ok(0) => break,
1106                    Ok(count) => collected
1107                        .lock()
1108                        .unwrap_or_else(std::sync::PoisonError::into_inner)
1109                        .extend_from_slice(&chunk[..count]),
1110                    Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
1111                    Err(error) => return Err(error),
1112                }
1113            }
1114            Ok(())
1115        });
1116        Self {
1117            bytes,
1118            stop,
1119            thread: Some(thread),
1120        }
1121    }
1122
1123    fn is_finished(&self) -> bool {
1124        self.thread
1125            .as_ref()
1126            .is_none_or(std::thread::JoinHandle::is_finished)
1127    }
1128
1129    /// Waits for end of stream until `deadline`, then stops the reader and
1130    /// returns everything it collected.
1131    fn finish(mut self, stream: &str, deadline: Instant) -> Result<Vec<u8>> {
1132        while !self.is_finished() && Instant::now() < deadline {
1133            std::thread::sleep(Duration::from_millis(5));
1134        }
1135        self.stop.store(true, Ordering::Release);
1136        self.thread
1137            .take()
1138            .context("command reader already joined")?
1139            .join()
1140            .map_err(|_| anyhow::anyhow!("command {stream} reader panicked"))?
1141            .with_context(|| format!("read command {stream}"))?;
1142        let mut bytes = self
1143            .bytes
1144            .lock()
1145            .unwrap_or_else(std::sync::PoisonError::into_inner);
1146        Ok(std::mem::take(&mut bytes))
1147    }
1148}
1149
1150impl Drop for PipeCollector {
1151    fn drop(&mut self) {
1152        self.stop.store(true, Ordering::Release);
1153    }
1154}
1155
1156fn terminate_cancellable_child(child: &mut std::process::Child, preserve_children: bool) {
1157    #[cfg(unix)]
1158    if preserve_children {
1159        if let Err(error) = child.kill()
1160            && error.kind() != std::io::ErrorKind::NotFound
1161        {
1162            tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
1163        }
1164    } else if let Err(error) =
1165        crate::subprocess::signal_process_group(child.id() as i32, libc::SIGKILL)
1166    {
1167        // A group that is already gone is the wanted outcome, not a failure.
1168        tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command process group");
1169    }
1170    #[cfg(not(unix))]
1171    {
1172        let _ = preserve_children;
1173        if let Err(error) = child.kill() {
1174            tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
1175        }
1176    }
1177    if let Err(error) = child.wait() {
1178        tracing::warn!(pid = child.id(), %error, "could not reap cancelled command");
1179    }
1180}
1181
1182impl CancellableProcessExecutor {
1183    /// One attempt, with no admission or retry of its own.
1184    fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
1185        if let Some(input) = &command.sensitive_stdin {
1186            let mut input = std::io::Cursor::new(input.0.as_slice());
1187            // Owned bytes, so each attempt gets its own reader.
1188            return stream_command_with_stdin(
1189                cancellable_command(command),
1190                command,
1191                &mut input,
1192                &|| self.is_cancelled(),
1193            );
1194        }
1195        let started = Instant::now();
1196        self.check_cancelled()?;
1197        let mut child = cancellable_command(command)
1198            .stdin(Stdio::null())
1199            .stdout(Stdio::piped())
1200            .stderr(Stdio::piped())
1201            .spawn()
1202            .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
1203        let group = (!command.detaches && !command.preserve_children_on_cancel)
1204            .then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
1205        let stdout = child.stdout.take().context("command stdout missing")?;
1206        let stderr = child.stderr.take().context("command stderr missing")?;
1207        let stdout_reader = PipeCollector::spawn(stdout);
1208        let stderr_reader = PipeCollector::spawn(stderr);
1209        let mut status = None;
1210        let mut exited_at = None;
1211        let status = loop {
1212            if self.is_cancelled() {
1213                terminate_cancellable_child(&mut child, command.preserve_children_on_cancel);
1214                let deadline = Instant::now() + IO_DRAIN_TIMEOUT;
1215                for (stream, reader) in [("stdout", stdout_reader), ("stderr", stderr_reader)] {
1216                    if let Err(error) = reader.finish(stream, deadline) {
1217                        tracing::warn!(stream, %error, "cancelled command reader failed");
1218                    }
1219                }
1220                bail!("operation cancelled while {}", command.purpose);
1221            }
1222            if status.is_none() {
1223                status = child
1224                    .try_wait()
1225                    .with_context(|| format!("wait for {}", command.purpose))?;
1226            }
1227            // The command completes when its leader exits. Descendants can
1228            // retain the pipes, so output is collected for a bounded time.
1229            if let Some(status) = status {
1230                let exited_at = *exited_at.get_or_insert_with(Instant::now);
1231                if (stdout_reader.is_finished() && stderr_reader.is_finished())
1232                    || exited_at.elapsed() >= IO_DRAIN_TIMEOUT
1233                {
1234                    break status;
1235                }
1236            }
1237            std::thread::sleep(Duration::from_millis(25));
1238        };
1239        let deadline = Instant::now();
1240        let stdout = stdout_reader.finish("stdout", deadline)?;
1241        let stderr = stderr_reader.finish("stderr", deadline)?;
1242        let status = status.code().unwrap_or(-1);
1243        drop(group);
1244        Ok(finished_command(command, started, status, stdout, stderr))
1245    }
1246}
1247
1248impl CommandExecutor for CancellableProcessExecutor {
1249    fn execute_cleanup(&self, command: &CommandSpec) -> Result<CommandOutput> {
1250        self.clone()
1251            .with_deadline(Duration::from_secs(15))
1252            .execute(command)
1253    }
1254
1255    fn execute_cleanup_with_timeout(
1256        &self,
1257        command: &CommandSpec,
1258        timeout: Duration,
1259    ) -> Result<CommandOutput> {
1260        self.clone().with_deadline(timeout).execute(command)
1261    }
1262
1263    fn cancellation_requested(&self) -> bool {
1264        self.is_cancelled()
1265    }
1266
1267    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1268        crate::runtime::off_async_worker(|| {
1269            let _running = BlockingOperation::command(command);
1270            with_ssh_admission(command, self, &|| self.is_cancelled(), |command| {
1271                self.run_once(command)
1272            })
1273        })
1274    }
1275
1276    fn execute_with_stdin(
1277        &self,
1278        command: &CommandSpec,
1279        input: &mut (dyn Read + Send),
1280    ) -> Result<CommandOutput> {
1281        crate::runtime::off_async_worker(|| {
1282            let _running = BlockingOperation::command(command);
1283            // A caller's stream cannot be replayed, so this path takes a
1284            // session and a permit but never retries.
1285            let session = command.open_ssh_session(self)?;
1286            let _permit = command
1287                .ssh_destination
1288                .as_deref()
1289                .map(|destination| {
1290                    SshAdmission::acquire_unless(destination, &|| self.is_cancelled())
1291                })
1292                .transpose()?;
1293            let command = session.command();
1294            // The child runs in its own process group so cancellation can kill
1295            // the whole group, which is what releases a writer blocked on a
1296            // full pipe.
1297            stream_command_with_stdin(cancellable_command(command), command, input, &|| {
1298                self.is_cancelled()
1299            })
1300        })
1301    }
1302}
1303
1304/// A command supervised by [`BoundedProcessExecutor`] did not finish before
1305/// its deadline. Callers downcast to this to tell a hung probe apart from a
1306/// command that could not be started at all.
1307#[derive(Debug, Clone, PartialEq, Eq)]
1308pub struct CommandTimedOut {
1309    pub program: String,
1310    pub purpose: String,
1311    pub timeout: Duration,
1312}
1313
1314impl std::fmt::Display for CommandTimedOut {
1315    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1316        write!(
1317            formatter,
1318            "`{}` did not answer within {} seconds while trying to {}",
1319            self.program,
1320            self.timeout.as_secs(),
1321            self.purpose
1322        )
1323    }
1324}
1325
1326impl std::error::Error for CommandTimedOut {}
1327
1328/// Runs every command with its own deadline.
1329///
1330/// [`CancellableProcessExecutor::with_timeout`] bounds a whole operation from a
1331/// single shared deadline, which suits one provisioning run. Prerequisite
1332/// probes are different: each one is expected to answer quickly, and a wedged
1333/// socket or blackholed network must not stall the probes that follow it. A
1334/// timeout here names the probe that hung, so the caller can report it the same
1335/// way it reports any other probe failure.
1336#[derive(Debug, Clone, Copy)]
1337pub struct BoundedProcessExecutor {
1338    timeout: Duration,
1339}
1340
1341impl BoundedProcessExecutor {
1342    pub const fn new(timeout: Duration) -> Self {
1343        Self { timeout }
1344    }
1345}
1346
1347impl CommandExecutor for BoundedProcessExecutor {
1348    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1349        let executor = CancellableProcessExecutor::with_timeout(self.timeout);
1350        executor.execute(command).map_err(|error| {
1351            if executor.is_cancelled() {
1352                anyhow::Error::new(CommandTimedOut {
1353                    program: command.program.clone(),
1354                    purpose: command.purpose.clone(),
1355                    timeout: self.timeout,
1356                })
1357            } else {
1358                error
1359            }
1360        })
1361    }
1362
1363    fn execute_cleanup_with_timeout(
1364        &self,
1365        command: &CommandSpec,
1366        timeout: Duration,
1367    ) -> Result<CommandOutput> {
1368        // This executor's configured timeout is a hard per-command cap; the
1369        // call-specific cleanup timeout can only make the attempt shorter.
1370        CancellableProcessExecutor::with_timeout(self.timeout.min(timeout)).execute(command)
1371    }
1372
1373    fn execute_with_stdin(
1374        &self,
1375        command: &CommandSpec,
1376        input: &mut (dyn Read + Send),
1377    ) -> Result<CommandOutput> {
1378        CancellableProcessExecutor::with_timeout(self.timeout).execute_with_stdin(command, input)
1379    }
1380}
1381
1382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1383pub struct CommandPlan {
1384    pub description: String,
1385    pub commands: Vec<CommandSpec>,
1386}
1387
1388impl CommandPlan {
1389    /// Supply one container environment value without placing it in the
1390    /// Podman/SSH argument vector. The target launcher reads the value from
1391    /// stdin, exports it, and asks the container engine to inherit it by name.
1392    pub fn provide_target_environment_secret(
1393        &mut self,
1394        target: &TargetTemplate,
1395        name: &str,
1396        value: &str,
1397    ) -> Result<()> {
1398        ensure!(
1399            !name.is_empty()
1400                && name.bytes().enumerate().all(|(index, byte)| byte == b'_'
1401                    || byte.is_ascii_alphabetic()
1402                    || (index > 0 && byte.is_ascii_digit())),
1403            "invalid secret environment variable name"
1404        );
1405        ensure!(
1406            !value.as_bytes().contains(&b'\n') && !value.as_bytes().contains(&b'\r'),
1407            "secret environment value cannot contain a newline"
1408        );
1409        let command = self
1410            .commands
1411            .iter_mut()
1412            .find(|command| command.creates_target)
1413            .context("provisioning plan has no target creation command")?;
1414        let read_and_export = format!("IFS= read -r {name} || exit 1; export {name};");
1415        match target {
1416            TargetTemplate::LocalPodman(_)
1417            | TargetTemplate::LocalDocker(_)
1418            | TargetTemplate::AppleContainer(_) => {
1419                let program = std::mem::replace(&mut command.program, "sh".to_owned());
1420                let args = std::mem::take(&mut command.args);
1421                command.args = vec![
1422                    "-c".to_owned(),
1423                    format!("{read_and_export} exec \"$@\""),
1424                    "mj-secret-env".to_owned(),
1425                    program,
1426                ];
1427                command.args.extend(args);
1428            }
1429            TargetTemplate::SshPodman { .. } | TargetTemplate::SshDocker { .. } => {
1430                let remote = command
1431                    .args
1432                    .last_mut()
1433                    .context("remote container command has no SSH command argument")?;
1434                *remote = format!("{read_and_export} exec {remote}");
1435            }
1436            TargetTemplate::LocalBare
1437            | TargetTemplate::AwsEc2(_)
1438            | TargetTemplate::SshBare { .. } => {
1439                bail!("target does not support inherited container environment")
1440            }
1441        }
1442        let mut input = value.as_bytes().to_vec();
1443        input.push(b'\n');
1444        command.sensitive_stdin = Some(SensitiveCommandInput(input));
1445        Ok(())
1446    }
1447
1448    pub fn execute(&self, executor: &impl CommandExecutor) -> Result<Vec<CommandOutput>> {
1449        let mut outputs = Vec::with_capacity(self.commands.len());
1450        for command in &self.commands {
1451            let output = executor.execute(command)?;
1452            if output.status != 0 {
1453                bail!(
1454                    "{} failed with status {}: {}",
1455                    command.purpose,
1456                    output.status,
1457                    String::from_utf8_lossy(&output.stderr)
1458                );
1459            }
1460            outputs.push(output);
1461        }
1462        Ok(outputs)
1463    }
1464
1465    /// Execute the plan the same way [`Self::execute`] does, except that
1466    /// commands sharing a [`CommandSpec::parallel_group`] marker and
1467    /// appearing consecutively in `commands` run concurrently as one batch.
1468    ///
1469    /// A batch starts only once every earlier command has succeeded, and a
1470    /// batch that fails reports the first failure in plan order regardless
1471    /// of which command finished first — the same fail-fast contract
1472    /// [`Self::execute`] provides between individual commands. This method
1473    /// requires a `Sync` executor because a batch shares it across threads;
1474    /// [`Self::execute`] keeps working with non-`Sync` executors such as
1475    /// test fakes built on `RefCell`.
1476    pub fn execute_concurrent(
1477        &self,
1478        executor: &(impl CommandExecutor + Sync),
1479    ) -> Result<Vec<CommandOutput>> {
1480        let mut outputs = Vec::with_capacity(self.commands.len());
1481        let mut index = 0;
1482        while index < self.commands.len() {
1483            let group = self.commands[index].parallel_group;
1484            let mut end = index + 1;
1485            if group.is_some() {
1486                while end < self.commands.len() && self.commands[end].parallel_group == group {
1487                    end += 1;
1488                }
1489            }
1490            let batch = &self.commands[index..end];
1491            if let [command] = batch {
1492                outputs.push(checked_command_output(command, executor.execute(command)?)?);
1493            } else {
1494                let results: Vec<Result<CommandOutput>> = std::thread::scope(|scope| {
1495                    let handles: Vec<_> = batch
1496                        .iter()
1497                        .map(|command| scope.spawn(|| executor.execute(command)))
1498                        .collect();
1499                    handles
1500                        .into_iter()
1501                        .map(|handle| match handle.join() {
1502                            Ok(result) => result,
1503                            Err(panic) => Err(anyhow::anyhow!(
1504                                "concurrent command thread panicked: {}",
1505                                command_thread_panic_message(panic.as_ref())
1506                            )),
1507                        })
1508                        .collect()
1509                });
1510                for (command, result) in batch.iter().zip(results) {
1511                    outputs.push(checked_command_output(command, result?)?);
1512                }
1513            }
1514            index = end;
1515        }
1516        Ok(outputs)
1517    }
1518
1519    /// Split the plan around the command that creates the session's target:
1520    /// the commands through that one, then the commands that run against a
1521    /// target which already exists.
1522    ///
1523    /// A plan that creates nothing — an existing project directory, say —
1524    /// splits into nothing, so a caller never arms a teardown for a target it
1525    /// did not bring into existence.
1526    pub fn split_at_target_creation(&self) -> Option<(Self, Self)> {
1527        let created = self
1528            .commands
1529            .iter()
1530            .position(|command| command.creates_target)?;
1531        let (creation, remainder) = self.commands.split_at(created + 1);
1532        Some((
1533            Self {
1534                description: self.description.clone(),
1535                commands: creation.to_vec(),
1536            },
1537            Self {
1538                description: self.description.clone(),
1539                commands: remainder.to_vec(),
1540            },
1541        ))
1542    }
1543}
1544
1545/// Fail the same way [`CommandPlan::execute`] does for a non-zero exit
1546/// status; kept as a shared helper so [`CommandPlan::execute_concurrent`]
1547/// reports identical error text.
1548pub fn checked_command_output(
1549    command: &CommandSpec,
1550    output: CommandOutput,
1551) -> Result<CommandOutput> {
1552    if output.status != 0 {
1553        bail!(
1554            "{} failed with status {}: {}",
1555            command.purpose,
1556            output.status,
1557            String::from_utf8_lossy(&output.stderr)
1558        );
1559    }
1560    Ok(output)
1561}
1562
1563/// Describe a spawned command thread's panic payload for error context.
1564pub fn command_thread_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
1565    if let Some(message) = payload.downcast_ref::<&str>() {
1566        (*message).to_owned()
1567    } else if let Some(message) = payload.downcast_ref::<String>() {
1568        message.clone()
1569    } else {
1570        "non-string panic payload".to_owned()
1571    }
1572}
1573
1574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1575pub struct RepositorySpec {
1576    /// Network clone URL. Managed workspaces require a configured remote.
1577    pub url: Option<String>,
1578    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1579    pub push_urls: Vec<String>,
1580    pub destination: String,
1581    pub git_ref: Option<String>,
1582    /// Read-only bare repository mounted into the target for Git object reuse.
1583    /// A missing or unusable reference is only an optimization miss.
1584    #[serde(default, skip_serializing_if = "Option::is_none")]
1585    pub reference: Option<String>,
1586}
1587
1588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1589pub struct ProjectBundleSpec {
1590    pub primary: String,
1591    pub repositories: Vec<RepositorySpec>,
1592}
1593
1594impl ProjectBundleSpec {
1595    pub fn validate(&self) -> Result<()> {
1596        validate_relative_path(&self.primary)?;
1597        if self.repositories.is_empty() {
1598            bail!("a project bundle must contain at least one repository");
1599        }
1600        let mut destinations = std::collections::BTreeSet::new();
1601        for repository in &self.repositories {
1602            validate_relative_path(&repository.destination)?;
1603            ensure!(
1604                repository
1605                    .url
1606                    .as_deref()
1607                    .is_some_and(|url| !url.trim().is_empty() && !url.starts_with('-')),
1608                "isolated repositories require a network Git remote; configure a remote or use a raw local session"
1609            );
1610            crate::remote_git::validate_network_url(
1611                repository.url.as_deref().expect("checked above"),
1612            )?;
1613            for push_url in &repository.push_urls {
1614                crate::remote_git::validate_network_url(push_url)?;
1615            }
1616            ensure!(
1617                repository.git_ref.is_none(),
1618                "git_ref is no longer supported; remove it to start from the remote's default branch"
1619            );
1620            if !destinations.insert(&repository.destination) {
1621                bail!(
1622                    "duplicate repository destination {}",
1623                    repository.destination
1624                );
1625            }
1626        }
1627        if !destinations.contains(&self.primary) {
1628            bail!("primary repository is not present in the bundle");
1629        }
1630        Ok(())
1631    }
1632}
1633
1634#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1635#[serde(tag = "kind", rename_all = "snake_case")]
1636pub enum PodmanWorkspaceStorage {
1637    PodmanVolume,
1638    HostHelper {
1639        root: String,
1640        helper: Vec<String>,
1641    },
1642    #[default]
1643    ContainerLayer,
1644}
1645
1646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1647pub struct ContainerTemplate {
1648    pub image: String,
1649    #[serde(default)]
1650    pub pull_policy: ImagePullPolicy,
1651    #[serde(default)]
1652    pub extra_run_args: Vec<String>,
1653    #[serde(default)]
1654    pub workspace_storage: PodmanWorkspaceStorage,
1655    /// Per-target mbx build cache overrides, carried from the user
1656    /// configuration so cache resolution can read them off a runtime target.
1657    #[serde(default)]
1658    pub build_cache: Option<crate::config::TargetBuildCache>,
1659}
1660
1661impl ImagePullPolicy {
1662    /// How fresh this target wants its image, with `Auto` read from the image
1663    /// reference. This is the freshness the background refresher acts on.
1664    pub fn resolve(self, image: &str) -> Self {
1665        if self != Self::Auto {
1666            return self;
1667        }
1668        if image_is_digest_pinned(image) {
1669            Self::Missing
1670        } else if image_is_remote(image) && image_uses_latest_tag(image) {
1671            Self::Newer
1672        } else {
1673            Self::Missing
1674        }
1675    }
1676
1677    /// How fresh a launch insists on being. `Auto` never pulls here: the daemon
1678    /// refreshes remote `:latest` images on its own schedule, so a session
1679    /// starts from the cached image instead of blocking a launch on a
1680    /// multi-gigabyte download. An explicit policy still means what it says.
1681    pub fn at_launch(self, image: &str) -> Self {
1682        if self == Self::Auto {
1683            Self::Missing
1684        } else {
1685            self.resolve(image)
1686        }
1687    }
1688
1689    /// What this policy does to `image`, in the words a settings screen can
1690    /// show. `Auto` is derived rather than an alias, so it names the launch
1691    /// behavior and the background refresh it implies, from the same image
1692    /// reading `resolve` uses.
1693    pub fn describe(self, image: &str) -> &'static str {
1694        match self {
1695            Self::Always => "Pull every launch",
1696            Self::Newer => "Pull when the registry is newer",
1697            Self::Missing => "Pull only if missing",
1698            Self::Never => "Never pull",
1699            Self::Auto => match self.resolve(image) {
1700                Self::Newer => "Pull if missing at launch; refresh :latest in background",
1701                _ => "Pull if missing",
1702            },
1703        }
1704    }
1705
1706    /// Podman's spelling of an already-resolved policy.
1707    pub fn podman_value(self) -> &'static str {
1708        match self {
1709            Self::Always => "always",
1710            Self::Newer => "newer",
1711            Self::Missing => "missing",
1712            Self::Never => "never",
1713            Self::Auto => unreachable!("auto pull policy must resolve"),
1714        }
1715    }
1716}
1717
1718/// Where a background image refresh runs.
1719///
1720/// The SSH form wraps commands the way provisioning does rather than the way
1721/// the preflight probes do: a pull runs for minutes, and the probes' two-second
1722/// keepalive would drop the connection underneath it.
1723#[derive(Debug, Clone, PartialEq, Eq)]
1724pub enum ImageHost {
1725    LocalPodman,
1726    LocalDocker,
1727    AppleContainer,
1728    SshPodman(SshTarget),
1729    SshDocker(SshTarget),
1730}
1731
1732impl ImageHost {
1733    pub const fn engine(&self) -> &'static str {
1734        match self {
1735            Self::LocalPodman | Self::SshPodman(_) => "podman",
1736            Self::LocalDocker | Self::SshDocker(_) => "docker",
1737            Self::AppleContainer => "container",
1738        }
1739    }
1740
1741    /// How this host is named in a log line.
1742    pub fn label(&self) -> String {
1743        match self {
1744            Self::LocalPodman => "local podman".to_owned(),
1745            Self::LocalDocker => "local docker".to_owned(),
1746            Self::AppleContainer => "apple container".to_owned(),
1747            Self::SshPodman(ssh) => format!("podman on {}", ssh.destination),
1748            Self::SshDocker(ssh) => format!("docker on {}", ssh.destination),
1749        }
1750    }
1751
1752    fn command(&self, args: Vec<String>, purpose: String) -> CommandSpec {
1753        match self {
1754            Self::LocalPodman | Self::LocalDocker | Self::AppleContainer => {
1755                CommandSpec::new(args[0].clone(), args[1..].iter().cloned())
1756            }
1757            Self::SshPodman(ssh) | Self::SshDocker(ssh) => ssh_command_owned(ssh, args),
1758        }
1759        .purpose(purpose)
1760    }
1761}
1762
1763/// When a background refresh downloads an image.
1764///
1765/// A host that already has the image is the common case, and most targets
1766/// only need the copy to exist. Ordering matters: merging two targets that
1767/// share an image takes the larger value, so one demanding target upgrades
1768/// the pair.
1769#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1770pub enum RefreshWhen {
1771    /// Pull only when the host has no copy (missing, or auto on a fixed tag).
1772    WhenAbsent,
1773    /// Pull every tick (always, or newer / auto on a moving tag).
1774    Always,
1775}
1776
1777/// The commands that keep one host's copy of one image current, away from any
1778/// session launch. They run in this order, and only for that host.
1779#[derive(Debug, Clone, PartialEq, Eq)]
1780pub struct ImageRefresh {
1781    pub host: ImageHost,
1782    pub image: String,
1783    pub platform: Option<String>,
1784    /// Whether this image is downloaded only when the host lacks it, or on
1785    /// every refresh.
1786    pub when: RefreshWhen,
1787    /// Reads the cached image id, so a pull that changed nothing stays quiet.
1788    /// Run before and after the pull.
1789    pub image_id: CommandSpec,
1790    pub pull: CommandSpec,
1791    /// Dangling images only. Both engines keep an image a container still uses.
1792    /// `None` for Apple's `container` engine, which has no verified prune form.
1793    pub prune: Option<CommandSpec>,
1794}
1795
1796/// The background refresh for one configured container target, or `None` when
1797/// the target asked never to download the image in the background.
1798///
1799/// `never` is the only opt-out. Every other policy at least wants the host to
1800/// have a copy, so the daemon downloads a missing image once; `always` and
1801/// `newer` keep the hourly pull they always had.
1802pub fn image_refresh(
1803    host: ImageHost,
1804    image: &str,
1805    platform: Option<&str>,
1806    pull_policy: ImagePullPolicy,
1807) -> Option<ImageRefresh> {
1808    let when = match pull_policy.resolve(image) {
1809        ImagePullPolicy::Always | ImagePullPolicy::Newer => RefreshWhen::Always,
1810        ImagePullPolicy::Missing => RefreshWhen::WhenAbsent,
1811        ImagePullPolicy::Never => return None,
1812        ImagePullPolicy::Auto => unreachable!("auto pull policy must resolve"),
1813    };
1814    let engine = host.engine();
1815    // Apple's `container` CLI has no `--format` on `image inspect`: the exit
1816    // status is what says the image is present, and the printed description
1817    // stands in for the id when comparing before and after a pull.
1818    let apple = matches!(host, ImageHost::AppleContainer);
1819    let mut image_id_args = vec![engine.to_owned(), "image".to_owned(), "inspect".to_owned()];
1820    if !apple {
1821        image_id_args.push("--format".to_owned());
1822        image_id_args.push("{{.Id}}".to_owned());
1823    }
1824    image_id_args.push(image.to_owned());
1825    let image_id = host.command(
1826        image_id_args,
1827        format!("read the cached id of container image {image}"),
1828    );
1829    let mut pull_args = vec![engine.to_owned()];
1830    if apple {
1831        pull_args.push("image".to_owned());
1832    }
1833    pull_args.push("pull".to_owned());
1834    // Apple's engine runs only native images, so it takes no platform.
1835    if let Some(platform) = platform.filter(|_| !apple) {
1836        pull_args.push(format!("--platform={platform}"));
1837    }
1838    pull_args.push(image.to_owned());
1839    let pull = host.command(pull_args, format!("refresh container image {image}"));
1840    let prune = (!apple).then(|| {
1841        host.command(
1842            vec![
1843                engine.to_owned(),
1844                "image".to_owned(),
1845                "prune".to_owned(),
1846                "-f".to_owned(),
1847            ],
1848            "remove dangling container images".to_owned(),
1849        )
1850    });
1851    Some(ImageRefresh {
1852        host,
1853        image: image.to_owned(),
1854        platform: platform.map(str::to_owned),
1855        when,
1856        image_id,
1857        pull,
1858        prune,
1859    })
1860}
1861
1862fn image_is_digest_pinned(image: &str) -> bool {
1863    image
1864        .rsplit_once('@')
1865        .is_some_and(|(_, digest)| !digest.is_empty())
1866}
1867
1868fn image_is_remote(image: &str) -> bool {
1869    !image.starts_with("localhost/") && !image.starts_with("local/")
1870}
1871
1872fn image_uses_latest_tag(image: &str) -> bool {
1873    let name = image.split_once('@').map_or(image, |(name, _)| name);
1874    let final_component = name.rsplit('/').next().unwrap_or(name);
1875    !final_component.contains(':') || final_component.ends_with(":latest")
1876}
1877
1878#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1879pub struct SshTarget {
1880    pub destination: String,
1881    #[serde(default)]
1882    pub ssh_args: Vec<String>,
1883}
1884
1885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1886pub struct AwsTemplate {
1887    pub profile: String,
1888    pub region: String,
1889    pub launch_template: String,
1890    pub launch_template_version: Option<String>,
1891    pub instance_type: Option<String>,
1892    pub ssh: SshTarget,
1893}
1894
1895#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1896#[serde(tag = "kind", rename_all = "snake_case")]
1897pub enum TargetTemplate {
1898    LocalBare,
1899    LocalPodman(ContainerTemplate),
1900    LocalDocker(ContainerTemplate),
1901    AppleContainer(ContainerTemplate),
1902    AwsEc2(AwsTemplate),
1903    SshBare {
1904        ssh: SshTarget,
1905        #[serde(default = "default_ssh_prefix")]
1906        workspace_prefix: String,
1907    },
1908    SshPodman {
1909        ssh: SshTarget,
1910        container: ContainerTemplate,
1911    },
1912    SshDocker {
1913        ssh: SshTarget,
1914        container: ContainerTemplate,
1915    },
1916}
1917
1918fn default_ssh_prefix() -> String {
1919    ".local/share/hel/workspaces".to_owned()
1920}
1921
1922#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1923#[serde(tag = "kind", rename_all = "snake_case")]
1924pub enum PodmanWorkspaceLocator {
1925    #[default]
1926    ContainerLayer,
1927    Volume {
1928        name: String,
1929    },
1930    HostPath {
1931        path: String,
1932        helper: Vec<String>,
1933        resource: String,
1934    },
1935}
1936
1937#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1938#[serde(tag = "kind", rename_all = "snake_case")]
1939pub enum TargetLocator {
1940    LocalBare {
1941        worker_root: String,
1942    },
1943    LocalPodman {
1944        container_id: String,
1945        #[serde(default)]
1946        workspace_storage: PodmanWorkspaceLocator,
1947        /// The session that owns the container when this locator is a
1948        /// sub-agent child borrowing its parent's container; `None` when the
1949        /// session owns the container itself.
1950        #[serde(default, skip_serializing_if = "Option::is_none")]
1951        borrowed_from: Option<String>,
1952    },
1953    LocalDocker {
1954        container_id: String,
1955        /// The session that owns the container when this locator is a
1956        /// sub-agent child borrowing its parent's container; `None` when the
1957        /// session owns the container itself.
1958        #[serde(default, skip_serializing_if = "Option::is_none")]
1959        borrowed_from: Option<String>,
1960    },
1961    AppleContainer {
1962        container_id: String,
1963        /// The session that owns the container when this locator is a
1964        /// sub-agent child borrowing its parent's container; `None` when the
1965        /// session owns the container itself.
1966        #[serde(default, skip_serializing_if = "Option::is_none")]
1967        borrowed_from: Option<String>,
1968    },
1969    AwsEc2 {
1970        profile: String,
1971        region: String,
1972        instance_id: String,
1973        ssh: SshTarget,
1974        workspace: String,
1975    },
1976    SshBare {
1977        ssh: SshTarget,
1978        workspace: String,
1979        /// A borrowed-target child has its own worker identity in the parent's workspace.
1980        #[serde(default, skip_serializing_if = "Option::is_none")]
1981        worker_id: Option<String>,
1982    },
1983    SshPodman {
1984        ssh: SshTarget,
1985        container_id: String,
1986        #[serde(default)]
1987        workspace_storage: PodmanWorkspaceLocator,
1988        /// The session that owns the container when this locator is a
1989        /// sub-agent child borrowing its parent's container; `None` when the
1990        /// session owns the container itself.
1991        #[serde(default, skip_serializing_if = "Option::is_none")]
1992        borrowed_from: Option<String>,
1993    },
1994    SshDocker {
1995        ssh: SshTarget,
1996        container_id: String,
1997        /// The session that owns the container when this locator is a
1998        /// sub-agent child borrowing its parent's container; `None` when the
1999        /// session owns the container itself.
2000        #[serde(default, skip_serializing_if = "Option::is_none")]
2001        borrowed_from: Option<String>,
2002    },
2003}
2004
2005impl TargetTemplate {
2006    pub const fn container_engine(&self) -> Option<&'static str> {
2007        match self {
2008            Self::LocalPodman(_) | Self::SshPodman { .. } => Some("podman"),
2009            Self::LocalDocker(_) | Self::SshDocker { .. } => Some("docker"),
2010            Self::AppleContainer(_) => Some("container"),
2011            _ => None,
2012        }
2013    }
2014
2015    /// The host that downloads this target's image, with the container
2016    /// settings that name the image. `None` for targets that run no image.
2017    pub fn image_host(&self) -> Option<(ImageHost, &ContainerTemplate)> {
2018        match self {
2019            Self::LocalPodman(container) => Some((ImageHost::LocalPodman, container)),
2020            Self::LocalDocker(container) => Some((ImageHost::LocalDocker, container)),
2021            Self::AppleContainer(container) => Some((ImageHost::AppleContainer, container)),
2022            Self::SshPodman { ssh, container } => {
2023                Some((ImageHost::SshPodman(ssh.clone()), container))
2024            }
2025            Self::SshDocker { ssh, container } => {
2026                Some((ImageHost::SshDocker(ssh.clone()), container))
2027            }
2028            Self::LocalBare | Self::AwsEc2(_) | Self::SshBare { .. } => None,
2029        }
2030    }
2031}
2032
2033impl TargetLocator {
2034    /// The target kind spelling shared with [`crate::config::TargetTemplate`].
2035    pub const fn kind_name(&self) -> &'static str {
2036        match self {
2037            Self::LocalBare { .. } => "local-bare",
2038            Self::LocalPodman { .. } => "local-podman",
2039            Self::LocalDocker { .. } => "local-docker",
2040            Self::AppleContainer { .. } => "apple-container",
2041            Self::AwsEc2 { .. } => "aws-ec2",
2042            Self::SshBare { .. } => "ssh-bare",
2043            Self::SshPodman { .. } => "ssh-podman",
2044            Self::SshDocker { .. } => "ssh-docker",
2045        }
2046    }
2047
2048    pub const fn container_engine(&self) -> Option<&'static str> {
2049        match self {
2050            Self::LocalPodman { .. } | Self::SshPodman { .. } => Some("podman"),
2051            Self::LocalDocker { .. } | Self::SshDocker { .. } => Some("docker"),
2052            Self::AppleContainer { .. } => Some("container"),
2053            _ => None,
2054        }
2055    }
2056}
2057
2058/// Commands and identity needed to bring a stopped managed target back online.
2059/// Only runtimes whose stopped resources retain their durable files provide
2060/// one; callers leave every other target kind alone.
2061#[derive(Debug, Clone, PartialEq, Eq)]
2062pub struct TargetRecoveryPlan {
2063    pub exists: CommandSpec,
2064    pub inspect: CommandSpec,
2065    pub start: CommandSpec,
2066    pub session_id: String,
2067}
2068
2069#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2070pub enum TargetRecoveryOutcome {
2071    NotRequired,
2072    Missing,
2073    AlreadyRunning,
2074    Started,
2075}
2076
2077pub fn resource_name(session_id: &str) -> Result<String> {
2078    validate_session_id(session_id)?;
2079    let readable: String = session_id
2080        .chars()
2081        .filter(|character| character.is_ascii_alphanumeric())
2082        .take(12)
2083        .map(|character| character.to_ascii_lowercase())
2084        .collect();
2085    let digest = Sha256::digest(session_id.as_bytes());
2086    Ok(format!(
2087        "mj-{readable}-{:02x}{:02x}{:02x}",
2088        digest[0], digest[1], digest[2]
2089    ))
2090}
2091
2092/// Resource generations let a Move retain its source while provisioning the destination.
2093pub fn move_resource_name(session_id: &str, operation_id: &str) -> Result<String> {
2094    let digest = Sha256::digest(operation_id.as_bytes());
2095    Ok(format!(
2096        "{}-move-{}",
2097        resource_name(session_id)?,
2098        crate::hex::lower_hex(&digest[..8])
2099    ))
2100}
2101
2102pub fn resource_name_belongs_to(name: &str, session_id: &str) -> Result<bool> {
2103    let base = resource_name(session_id)?;
2104    Ok(name == base
2105        || name
2106            .strip_prefix(&format!("{base}-move-"))
2107            .is_some_and(|suffix| {
2108                suffix.len() == 16 && suffix.bytes().all(|b| b.is_ascii_hexdigit())
2109            }))
2110}
2111
2112pub fn podman_workspace_locator(
2113    template: &ContainerTemplate,
2114    session_id: &str,
2115) -> Result<PodmanWorkspaceLocator> {
2116    podman_workspace_locator_named(template, &resource_name(session_id)?)
2117}
2118
2119pub fn podman_workspace_locator_named(
2120    template: &ContainerTemplate,
2121    name: &str,
2122) -> Result<PodmanWorkspaceLocator> {
2123    let resource = format!("{name}-workspace");
2124    match &template.workspace_storage {
2125        PodmanWorkspaceStorage::PodmanVolume => {
2126            Ok(PodmanWorkspaceLocator::Volume { name: resource })
2127        }
2128        PodmanWorkspaceStorage::HostHelper { root, helper } => {
2129            let root = Path::new(root);
2130            ensure!(
2131                crate::target_path::is_absolute(root),
2132                "Podman workspace storage root must be absolute"
2133            );
2134            ensure!(
2135                !helper.is_empty() && helper.iter().all(|argument| !argument.is_empty()),
2136                "Podman workspace storage helper must contain non-empty arguments"
2137            );
2138            Ok(PodmanWorkspaceLocator::HostPath {
2139                path: root.join(&resource).to_string_lossy().into_owned(),
2140                helper: helper.clone(),
2141                resource,
2142            })
2143        }
2144        PodmanWorkspaceStorage::ContainerLayer => Ok(PodmanWorkspaceLocator::ContainerLayer),
2145    }
2146}
2147
2148/// The in-container workspace root a session's repositories live under.
2149///
2150/// `recorded` is the session record's `container_workspace`. A session whose
2151/// container predates per-session workspaces has none and keeps the legacy
2152/// shared `/workspace`; every session created since records
2153/// `/workspace/<session id>`, so a build cache shared by every container on a
2154/// host never sees two checkouts of one project at the same absolute path.
2155pub fn container_workspace_root(recorded: Option<&Path>) -> String {
2156    recorded.map_or_else(
2157        || CONTAINER_WORKSPACE.to_owned(),
2158        |path| path.to_string_lossy().into_owned(),
2159    )
2160}
2161
2162/// The per-session container workspace recorded for a session created now.
2163pub fn new_container_workspace(session_id: &str) -> Result<PathBuf> {
2164    validate_session_id(session_id)?;
2165    Ok(Path::new(CONTAINER_WORKSPACE).join(session_id))
2166}
2167
2168/// The workspace directory an EC2 session owns in its login home.
2169///
2170/// EC2 instances are provisioned per session, so the path is decided by the
2171/// session id alone and is the same whether it is read from a target template
2172/// or rebuilt from a stored locator.
2173pub fn aws_workspace(session_id: &str) -> String {
2174    format!(".local/share/hel/workspaces/{session_id}")
2175}
2176
2177pub fn workspace_for(template: &TargetTemplate, session_id: &str) -> Result<String> {
2178    validate_session_id(session_id)?;
2179    match template {
2180        TargetTemplate::LocalBare => bail!("local bare projects use their selected directory"),
2181        // A container workspace is per session and recorded on the session
2182        // record, so it is read with `container_workspace_root` instead.
2183        TargetTemplate::LocalPodman(_)
2184        | TargetTemplate::LocalDocker(_)
2185        | TargetTemplate::AppleContainer(_)
2186        | TargetTemplate::SshPodman { .. }
2187        | TargetTemplate::SshDocker { .. } => {
2188            bail!("container targets use the session's recorded container workspace")
2189        }
2190        TargetTemplate::AwsEc2(_) => Ok(aws_workspace(session_id)),
2191        TargetTemplate::SshBare {
2192            workspace_prefix, ..
2193        } => {
2194            validate_workspace_prefix(workspace_prefix)?;
2195            // Interpret a leading "~/" as home-relative. Remote commands are
2196            // single-quoted, so a literal tilde would name a directory called
2197            // "~"; a relative path resolves against the login home for ssh
2198            // and scp alike.
2199            let prefix = workspace_prefix
2200                .strip_prefix("~/")
2201                .unwrap_or(workspace_prefix);
2202            Ok(format!("{}/{session_id}", prefix.trim_end_matches('/')))
2203        }
2204    }
2205}
2206
2207/// Wrap an argv vector for execution at a provisioned session target.
2208pub fn command_on_locator(
2209    locator: &TargetLocator,
2210    session_id: &str,
2211    args: Vec<String>,
2212    purpose: impl Into<String>,
2213) -> Result<CommandSpec> {
2214    verify_locator(locator, session_id)?;
2215    if args.is_empty() {
2216        bail!("target command must not be empty");
2217    }
2218    Ok(locator_command(locator, args).purpose(purpose))
2219}
2220
2221/// Wrap a non-empty argv vector for execution at a target, without checking
2222/// that the locator belongs to a particular session. Every per-target command
2223/// is built here, so each target kind is spelled out once.
2224pub fn locator_command(locator: &TargetLocator, args: Vec<String>) -> CommandSpec {
2225    match locator {
2226        TargetLocator::LocalBare { .. } => {
2227            let mut args = args.into_iter();
2228            let program = args.next().expect("target command must not be empty");
2229            CommandSpec::new(program, args)
2230        }
2231        TargetLocator::LocalPodman { container_id, .. }
2232        | TargetLocator::LocalDocker { container_id, .. }
2233        | TargetLocator::AppleContainer { container_id, .. } => container_exec(
2234            locator.container_engine().expect("local container"),
2235            container_id,
2236            args,
2237        ),
2238        TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
2239            ssh_command_owned(ssh, args)
2240        }
2241        TargetLocator::SshPodman {
2242            ssh, container_id, ..
2243        }
2244        | TargetLocator::SshDocker {
2245            ssh, container_id, ..
2246        } => {
2247            let mut remote = vec![
2248                locator
2249                    .container_engine()
2250                    .expect("remote container")
2251                    .to_owned(),
2252                "exec".to_owned(),
2253                "-i".to_owned(),
2254                container_id.to_owned(),
2255            ];
2256            remote.extend(args);
2257            ssh_command_owned(ssh, remote)
2258        }
2259    }
2260}
2261pub fn worker_root(locator: &TargetLocator, session_id: &str) -> Result<String> {
2262    verify_locator(locator, session_id)?;
2263    Ok(match locator {
2264        TargetLocator::LocalBare { worker_root } => worker_root.clone(),
2265        TargetLocator::LocalPodman { .. }
2266        | TargetLocator::LocalDocker { .. }
2267        | TargetLocator::AppleContainer { .. }
2268        | TargetLocator::SshPodman { .. }
2269        | TargetLocator::SshDocker { .. } => format!("/var/lib/hel/workers/{session_id}"),
2270        TargetLocator::AwsEc2 { .. } => format!(".local/share/hel/workers/{session_id}"),
2271        TargetLocator::SshBare { worker_id, .. } => format!(
2272            ".local/share/hel/workers/{}",
2273            worker_id.as_deref().unwrap_or(session_id)
2274        ),
2275    })
2276}
2277mod convert;
2278pub use convert::{
2279    RecordedTarget, StoredTarget, TargetConversionError, locator_needs_connection,
2280    ssh_args_with_identity,
2281};
2282
2283mod ssh;
2284pub use ssh::*;
2285
2286pub fn container_exec(
2287    engine: &str,
2288    container_id: &str,
2289    args: impl IntoIterator<Item = impl Into<String>>,
2290) -> CommandSpec {
2291    let mut command_args = vec!["exec".to_owned(), "-i".to_owned(), container_id.to_owned()];
2292    command_args.extend(args.into_iter().map(Into::into));
2293    CommandSpec::new(engine, command_args)
2294}
2295
2296#[cfg(all(test, unix))]
2297mod executor_tests {
2298    use std::fs;
2299
2300    use super::*;
2301
2302    #[cfg(target_os = "linux")]
2303    #[test]
2304    fn successful_commands_stop_descendants_that_closed_their_pipes() {
2305        for streamed in [false, true] {
2306            let temp = tempfile::tempdir().unwrap();
2307            let pid_file = temp.path().join("descendant");
2308            let command = CommandSpec::new("sh", [
2309                "-c".to_owned(),
2310                "cat >/dev/null; head -c 131072 /dev/zero; sleep 60 </dev/null >/dev/null 2>&1 & echo $! > \"$1\"".to_owned(),
2311                "owned-descendant".to_owned(), pid_file.display().to_string(),
2312            ]);
2313            let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(5));
2314            let output = if streamed {
2315                executor
2316                    .execute_with_stdin(&command, &mut std::io::Cursor::new(vec![b'x'; 256 * 1024]))
2317            } else {
2318                executor.execute(&command)
2319            }
2320            .unwrap();
2321            assert_eq!(output.stdout.len(), 131072);
2322            let pid: i32 = fs::read_to_string(pid_file)
2323                .unwrap()
2324                .trim()
2325                .parse()
2326                .unwrap();
2327            let deadline = Instant::now() + Duration::from_secs(2);
2328            loop {
2329                let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2330                if state.as_ref().is_none_or(|state| {
2331                    state
2332                        .rsplit_once(") ")
2333                        .is_some_and(|(_, fields)| fields.starts_with('Z'))
2334                }) {
2335                    break;
2336                }
2337                if Instant::now() >= deadline {
2338                    // Do not leave the fixture behind when the regression fails.
2339                    unsafe {
2340                        libc::kill(pid, libc::SIGKILL);
2341                    }
2342                    panic!(
2343                        "successful command left descendant {pid} running (streamed={streamed})"
2344                    );
2345                }
2346                std::thread::sleep(Duration::from_millis(10));
2347            }
2348        }
2349    }
2350
2351    /// B-2 at the executor: a backgrounded descendant that holds the output
2352    /// pipes must not hold the completed command past the drain.
2353    // Hard-won: c86b2123: A target command stayed Running until a background descendant closed inherited pipes.
2354    #[cfg(target_os = "linux")]
2355    #[test]
2356    fn commands_complete_at_leader_exit_when_a_descendant_holds_the_pipes() {
2357        for streamed in [false, true] {
2358            let temp = tempfile::tempdir().unwrap();
2359            let pid_file = temp.path().join("descendant");
2360            let command = CommandSpec::new(
2361                "sh",
2362                [
2363                    "-c".to_owned(),
2364                    "sleep 300 & echo $! > \"$1\"; echo hi; exit 3".to_owned(),
2365                    "held-pipes".to_owned(),
2366                    pid_file.display().to_string(),
2367                ],
2368            );
2369            let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(30));
2370            let started = Instant::now();
2371            let output = if streamed {
2372                executor.execute_with_stdin(&command, &mut std::io::Cursor::new(b"input".to_vec()))
2373            } else {
2374                executor.execute(&command)
2375            }
2376            .unwrap();
2377            assert!(
2378                started.elapsed() < Duration::from_secs(5),
2379                "streamed={streamed} took {:?}",
2380                started.elapsed()
2381            );
2382            assert_eq!(output.status, 3);
2383            assert_eq!(output.stdout, b"hi\n");
2384            let pid: i32 = fs::read_to_string(pid_file)
2385                .unwrap()
2386                .trim()
2387                .parse()
2388                .unwrap();
2389            let deadline = Instant::now() + Duration::from_secs(2);
2390            loop {
2391                let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2392                if state.as_ref().is_none_or(|state| {
2393                    state
2394                        .rsplit_once(") ")
2395                        .is_some_and(|(_, fields)| fields.starts_with('Z'))
2396                }) {
2397                    break;
2398                }
2399                if Instant::now() >= deadline {
2400                    // SAFETY: cleans up this test's own fixture.
2401                    unsafe {
2402                        libc::kill(pid, libc::SIGKILL);
2403                    }
2404                    panic!("completed command left descendant {pid} (streamed={streamed})");
2405                }
2406                std::thread::sleep(Duration::from_millis(10));
2407            }
2408        }
2409    }
2410
2411    #[test]
2412    fn streamed_deadline_survives_leader_exit_and_inherited_pipes() {
2413        let command = CommandSpec::new(
2414            "sh",
2415            [
2416                "-c",
2417                "head -c 131072 /dev/zero; cat >/dev/null; (trap '' TERM; sleep 60) & exit 0",
2418            ],
2419        );
2420        let mut input = std::io::Cursor::new(vec![b'x'; 256 * 1024]);
2421        let started = Instant::now();
2422        let error = CancellableProcessExecutor::with_timeout(Duration::from_millis(300))
2423            .execute_with_stdin(&command, &mut input)
2424            .unwrap_err();
2425        assert!(error.to_string().contains("cancelled"), "{error:#}");
2426        assert!(started.elapsed() < Duration::from_secs(5));
2427    }
2428
2429    #[test]
2430    fn cleanup_timeout_replaces_an_expired_deadline_and_keeps_shared_cancellation() {
2431        let directory = tempfile::tempdir().unwrap();
2432        let marker = directory.path().join("completed");
2433        let command = CommandSpec::new(
2434            "sh",
2435            [
2436                "-c".to_owned(),
2437                "printf ran > \"$1\"".to_owned(),
2438                "cleanup-deadline".to_owned(),
2439                marker.display().to_string(),
2440            ],
2441        );
2442        let cancelled = Arc::new(AtomicBool::new(false));
2443        let executor =
2444            CancellableProcessExecutor::new(cancelled.clone()).with_deadline(Duration::ZERO);
2445
2446        let output = executor
2447            .execute_cleanup_with_timeout(&command, Duration::from_secs(5))
2448            .unwrap();
2449
2450        assert_eq!(output.status, 0);
2451        assert_eq!(fs::read_to_string(marker).unwrap(), "ran");
2452
2453        cancelled.store(true, Ordering::Release);
2454        let error = executor
2455            .execute_cleanup_with_timeout(&command, Duration::from_secs(5))
2456            .unwrap_err();
2457        assert!(
2458            error.to_string().contains("operation cancelled"),
2459            "{error:#}"
2460        );
2461    }
2462
2463    /// A stand-in for `ssh` that is refused by the server on its first call and
2464    /// connects on the next, the way a host at its `MaxStartups` ceiling
2465    /// behaves once the daemon's burst drains.
2466    fn flaky_ssh_script(directory: &Path) -> CommandSpec {
2467        let counter = directory.join("attempts");
2468        let script = format!(
2469            "count=$(cat {counter} 2>/dev/null || echo 0)\n\
2470             echo $((count + 1)) > {counter}\n\
2471             if [ \"$count\" -eq 0 ]; then\n\
2472             echo 'kex_exchange_identification: Connection closed by 10.0.0.1 port 22' >&2\n\
2473             exit 255\n\
2474             fi\n\
2475             echo connected\n",
2476            counter = counter.display()
2477        );
2478        CommandSpec::new("sh", ["-c".to_owned(), script])
2479            .ssh_destination("build@10.0.0.1")
2480            .purpose("run the flaky SSH fixture")
2481    }
2482
2483    fn attempts(directory: &Path) -> u32 {
2484        fs::read_to_string(directory.join("attempts"))
2485            .expect("the fixture records its attempts")
2486            .trim()
2487            .parse()
2488            .expect("attempt count is a number")
2489    }
2490
2491    /// An executor that models `ssh` masters and the sessions on them. The
2492    /// first session finds its master dead (it was killed after the ledger
2493    /// last checked it), which `ssh` reports the way a session guarded with
2494    /// `ProxyCommand=false` does.
2495    #[derive(Default)]
2496    struct MasterKilledOnce {
2497        running: std::cell::RefCell<BTreeSet<String>>,
2498        openers: std::cell::Cell<usize>,
2499        sessions: std::cell::RefCell<Vec<Vec<String>>>,
2500    }
2501
2502    impl CommandExecutor for MasterKilledOnce {
2503        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2504            let reply = |status: i32, stderr: &str| CommandOutput {
2505                status,
2506                stdout: Vec::new(),
2507                stderr: stderr.as_bytes().to_vec(),
2508            };
2509            if command.ssh_session.is_some() {
2510                // A session. The first one arrives just after its master died.
2511                return with_ssh_admission(command, self, &|| false, |spawned| {
2512                    self.sessions.borrow_mut().push(spawned.args.clone());
2513                    if self.sessions.borrow().len() == 1 {
2514                        self.running.borrow_mut().clear();
2515                        return Ok(reply(255, "Connection closed by UNKNOWN port 65535"));
2516                    }
2517                    Ok(reply(0, ""))
2518                });
2519            }
2520            let socket = command
2521                .args
2522                .iter()
2523                .find_map(|arg| arg.strip_prefix("ControlPath="))
2524                .expect("a master command names its socket")
2525                .to_owned();
2526            if command.args.windows(2).any(|pair| pair == ["-O", "check"]) {
2527                return Ok(reply(
2528                    if self.running.borrow().contains(&socket) {
2529                        0
2530                    } else {
2531                        255
2532                    },
2533                    "",
2534                ));
2535            }
2536            assert!(command.args.contains(&"ControlMaster=yes".to_owned()));
2537            self.openers.set(self.openers.get() + 1);
2538            self.running.borrow_mut().insert(socket);
2539            Ok(reply(0, ""))
2540        }
2541    }
2542
2543    #[test]
2544    fn a_session_whose_master_died_is_retried_on_a_reopened_master() {
2545        let _guard = ssh::SHARING_TEST_LOCK
2546            .lock()
2547            .unwrap_or_else(std::sync::PoisonError::into_inner);
2548        set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2549        let socket_dir = tempfile::tempdir_in("/tmp").expect("short socket directory");
2550        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2551            socket_dir.path().to_path_buf(),
2552        )));
2553        let ssh = SshTarget {
2554            destination: "master-killed-once-host".to_owned(),
2555            ssh_args: Vec::new(),
2556        };
2557        let executor = MasterKilledOnce::default();
2558        let output = executor.execute(&ssh_command(&ssh, ["true"]));
2559        set_ssh_connection_sharing_for_test(None);
2560        set_ssh_retry_backoff_for_test(None);
2561
2562        assert_eq!(output.expect("the retry succeeds").status, 0);
2563        let sessions = executor.sessions.borrow();
2564        assert_eq!(sessions.len(), 2);
2565        assert_eq!(
2566            executor.openers.get(),
2567            2,
2568            "the retry reopens the master instead of trusting the earlier check"
2569        );
2570        for args in sessions.iter() {
2571            assert_eq!(args[..6][5], "ProxyCommand=false", "{args:?}");
2572        }
2573    }
2574
2575    // Hard-won: e6ed54ed: sshd MaxStartups rejected concurrent session handshakes and users saw dead workers and failed closes.
2576    #[test]
2577    fn a_transport_rejected_ssh_command_is_retried_once_and_then_succeeds() {
2578        set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2579        let directory = tempfile::tempdir().expect("temp dir");
2580        let command = flaky_ssh_script(directory.path());
2581
2582        let output = ProcessExecutor
2583            .execute(&command)
2584            .expect("the retry must reach the successful attempt");
2585
2586        assert_eq!(output.status, 0);
2587        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "connected");
2588        assert_eq!(attempts(directory.path()), 2);
2589        set_ssh_retry_backoff_for_test(None);
2590    }
2591
2592    // Hard-won: e6ed54ed: sshd MaxStartups rejected remote operations during daemon startup and they were falsely reported as failed.
2593    #[test]
2594    fn the_cancellable_executor_also_retries_a_transport_rejection() {
2595        set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2596        let directory = tempfile::tempdir().expect("temp dir");
2597        let command = flaky_ssh_script(directory.path());
2598
2599        let output = CancellableProcessExecutor::new(Arc::new(AtomicBool::new(false)))
2600            .execute(&command)
2601            .expect("the retry must reach the successful attempt");
2602
2603        assert_eq!(output.status, 0);
2604        assert_eq!(attempts(directory.path()), 2);
2605        set_ssh_retry_backoff_for_test(None);
2606    }
2607}