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    /// The durable Move owns this interruptible copy; daemon handoff may resume it.
619    fn begin_resumable_move_work(&self) -> Result<()> {
620        Ok(())
621    }
622    fn end_resumable_move_work(&self) -> Result<()> {
623        Ok(())
624    }
625
626    fn execute_with_stdin(
627        &self,
628        _command: &CommandSpec,
629        _input: &mut (dyn Read + Send),
630    ) -> Result<CommandOutput> {
631        bail!("this command executor does not support streamed stdin")
632    }
633}
634
635/// A scoped lifecycle-stage report for controller-side work or a sequence of
636/// commands. Dropping the guard reports completion even when the work returns
637/// early with an error.
638pub struct ProvisionStageGuard<'a, E: CommandExecutor + ?Sized> {
639    executor: &'a E,
640    stage: ProvisionStage,
641}
642
643impl<'a, E: CommandExecutor + ?Sized> ProvisionStageGuard<'a, E> {
644    pub fn new(executor: &'a E, stage: ProvisionStage) -> Self {
645        executor.stage_started(stage.clone());
646        Self { executor, stage }
647    }
648}
649
650impl<E: CommandExecutor + ?Sized> Drop for ProvisionStageGuard<'_, E> {
651    fn drop(&mut self) {
652        self.executor.stage_finished(self.stage.clone());
653    }
654}
655
656pub struct ProcessExecutor;
657
658/// Run one `ssh` invocation under process-wide admission control, retrying it
659/// when the server turned the connection away before authentication.
660///
661/// A permit is held only while a child is actually running and is released
662/// between attempts, because a waiting retry occupies no connection slot. A
663/// transport rejection means the remote command never started, so re-running
664/// the whole invocation cannot repeat a side effect.
665///
666/// A command that asks for a shared-connection session leases it on each
667/// attempt, before taking its permit: opening a master takes a permit of its
668/// own, and waiting for that while holding one could exhaust the gate. A
669/// session turned away by the transport usually means its master died, so
670/// the lease is invalidated and the retry checks and reopens the master.
671///
672/// Commands that are not tagged with a destination run untouched.
673fn with_ssh_admission(
674    command: &CommandSpec,
675    executor: &dyn CommandExecutor,
676    is_cancelled: &dyn Fn() -> bool,
677    mut run: impl FnMut(&CommandSpec) -> Result<CommandOutput>,
678) -> Result<CommandOutput> {
679    let Some(destination) = command.ssh_destination.as_deref() else {
680        return run(command);
681    };
682    for attempt in 1..=SSH_RETRY_ATTEMPTS {
683        let session = command.open_ssh_session(executor)?;
684        let output = {
685            let _permit = SshAdmission::acquire_unless(destination, is_cancelled)?;
686            run(session.command())?
687        };
688        let refusal = ssh_refusal(output.status, &String::from_utf8_lossy(&output.stderr));
689        // A refused session found its master alive; any other refusal may
690        // mean the master is gone.
691        if refusal == Some(SshRefusal::BeforeAuthentication)
692            && let Some(lease) = session.lease()
693        {
694            lease.invalidate();
695        }
696        drop(session);
697        let Some(refusal) = refusal else {
698            return Ok(output);
699        };
700        let stderr = String::from_utf8_lossy(&output.stderr);
701        if attempt == SSH_RETRY_ATTEMPTS {
702            refusal.log_exhausted(destination, &command.purpose, stderr.trim());
703            return Ok(output);
704        }
705        let delay = ssh_retry_delay(attempt);
706        refusal.log_retry(destination, &command.purpose, attempt, delay, stderr.trim());
707        if !sleep_unless_cancelled(delay, is_cancelled) {
708            bail!("operation cancelled while {}", command.purpose);
709        }
710    }
711    unreachable!("the final attempt always returns");
712}
713
714/// Wait out `delay`, giving up early if the supervising operation is
715/// cancelled. Returns whether the wait completed.
716fn sleep_unless_cancelled(delay: Duration, is_cancelled: &dyn Fn() -> bool) -> bool {
717    let deadline = Instant::now() + delay;
718    loop {
719        if is_cancelled() {
720            return false;
721        }
722        let remaining = deadline.saturating_duration_since(Instant::now());
723        if remaining.is_zero() {
724            return true;
725        }
726        std::thread::sleep(remaining.min(Duration::from_millis(50)));
727    }
728}
729
730/// Every process executor finishes a command here: one debug line, so a slow
731/// launch or resume phase can be attributed from logs instead of re-profiled
732/// by hand, and one look at a failure for a full disk on the target.
733fn finished_command(
734    command: &CommandSpec,
735    started: Instant,
736    status: i32,
737    stdout: Vec<u8>,
738    stderr: Vec<u8>,
739) -> CommandOutput {
740    tracing::debug!(
741        purpose = command.purpose.as_str(),
742        program = command.program.as_str(),
743        status,
744        elapsed_ms = started.elapsed().as_millis() as u64,
745        "target command finished"
746    );
747    let output = CommandOutput {
748        status,
749        stdout,
750        stderr,
751    };
752    storage::observe_command_output(command, &output);
753    output
754}
755
756impl ProcessExecutor {
757    /// One attempt, with no admission or retry of its own.
758    fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
759        if let Some(input) = &command.sensitive_stdin {
760            let mut input = std::io::Cursor::new(input.0.as_slice());
761            // Owned bytes, so each attempt gets its own reader.
762            return stream_command_with_stdin(
763                cancellable_command(command),
764                command,
765                &mut input,
766                &|| false,
767            );
768        }
769        let started = Instant::now();
770        let output = configured_command(command)
771            .stdin(Stdio::null())
772            .output()
773            .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
774        let status = output.status.code().unwrap_or(-1);
775        Ok(finished_command(
776            command,
777            started,
778            status,
779            output.stdout,
780            output.stderr,
781        ))
782    }
783}
784
785// Both process executors wait for their child, and for SSH admission before
786// it, through `off_async_worker`: a caller several calls below an `async fn`
787// must not hold one of the runtime's async worker threads for the length of a
788// target command.
789impl CommandExecutor for ProcessExecutor {
790    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
791        crate::runtime::off_async_worker(|| {
792            let _running = BlockingOperation::command(command);
793            with_ssh_admission(command, self, &|| false, |command| self.run_once(command))
794        })
795    }
796
797    fn execute_with_stdin(
798        &self,
799        command: &CommandSpec,
800        input: &mut (dyn Read + Send),
801    ) -> Result<CommandOutput> {
802        crate::runtime::off_async_worker(|| {
803            let _running = BlockingOperation::command(command);
804            // A caller's stream cannot be replayed, so this path takes a
805            // session and a permit but never retries.
806            let session = command.open_ssh_session(self)?;
807            let _permit = command
808                .ssh_destination
809                .as_deref()
810                .map(SshAdmission::acquire);
811            let command = session.command();
812            let process = cancellable_command(command);
813            // Plain process execution is not cancellable, so the transfer only
814            // ends when the child does.
815            stream_command_with_stdin(process, command, input, &|| false)
816        })
817    }
818}
819
820/// Streams `input` into a freshly spawned child and collects its output.
821///
822/// Both executors share this one implementation because the pipe edge cases
823/// below are easy to get subtly wrong in a second copy.
824///
825/// `is_cancelled` reports whether the supervising operation wants the transfer
826/// abandoned; [`ProcessExecutor`] passes a check that is never true, which also
827/// makes the kill path below unreachable for it.
828fn stream_command_with_stdin(
829    mut process: Command,
830    command: &CommandSpec,
831    input: &mut (dyn Read + Send),
832    is_cancelled: &(dyn Fn() -> bool + Sync),
833) -> Result<CommandOutput> {
834    let started = Instant::now();
835    if is_cancelled() {
836        bail!("operation cancelled");
837    }
838    let mut child = process
839        .stdin(Stdio::piped())
840        .stdout(Stdio::piped())
841        .stderr(Stdio::piped())
842        .spawn()
843        .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
844    let group =
845        (!command.detaches).then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
846    let stdin = child
847        .stdin
848        .take()
849        .context("streamed command stdin missing")?;
850    let stdout = child
851        .stdout
852        .take()
853        .context("streamed command stdout missing")?;
854    let stderr = child
855        .stderr
856        .take()
857        .context("streamed command stderr missing")?;
858    // Reader threads keep the child's output pipes drained; a child that fills
859    // one while nobody reads would block instead of exiting.
860    let stdout_reader = PipeCollector::spawn(stdout);
861    let stderr_reader = PipeCollector::spawn(stderr);
862    let process_result = std::thread::scope(|scope| -> Result<_> {
863        // Pipe writes can block forever when a remote helper stops reading.
864        // Keep the writer off the supervising thread so cancellation can kill
865        // the process group and thereby close the blocked pipe.
866        let input_writer = scope.spawn(move || -> Result<()> {
867            // Owning `stdin` here is what closes the pipe's write end once the
868            // transfer finishes. A child that reads to EOF, such as
869            // `mj worker export-checkpoint --spec -`, never exits while any
870            // copy of the write end is still open.
871            let mut stdin = stdin;
872            let mut buffer = [0_u8; 64 * 1024];
873            loop {
874                // Checking before each chunk makes large checkpoint copies
875                // cooperatively cancellable without changing the executor
876                // interface.
877                if is_cancelled() {
878                    bail!("operation cancelled");
879                }
880                let count = input.read(&mut buffer).context("read command input")?;
881                if count == 0 {
882                    break;
883                }
884                stdin
885                    .write_all(&buffer[..count])
886                    .context("stream command input")?;
887            }
888            stdin.flush().context("flush command input")
889        });
890        let mut status = None;
891        let mut exited_at = None;
892        let mut group_killed = false;
893        let status = loop {
894            if is_cancelled() {
895                terminate_cancellable_child(&mut child, false);
896                if let Err(error) = input_writer.join() {
897                    tracing::warn!(
898                        purpose = command.purpose.as_str(),
899                        "streamed command input writer panicked while cancelling: {error:?}"
900                    );
901                }
902                bail!("operation cancelled while {}", command.purpose);
903            }
904            match if status.is_some() {
905                Ok(status)
906            } else {
907                child.try_wait()
908            } {
909                Ok(observed) => status = observed,
910                Err(error) => {
911                    terminate_cancellable_child(&mut child, false);
912                    if let Err(join_error) = input_writer.join() {
913                        tracing::warn!(
914                            purpose = command.purpose.as_str(),
915                            "streamed command input writer panicked while waiting: {join_error:?}"
916                        );
917                    }
918                    return Err(error).with_context(|| format!("wait for {}", command.purpose));
919                }
920            }
921            // The command completes when its leader exits. Descendants can
922            // retain the pipes, so output is collected for a bounded time;
923            // then the owned group is terminated, which also releases a
924            // writer blocked on a pipe a descendant holds.
925            if let Some(status) = status {
926                let exited_at = *exited_at.get_or_insert_with(Instant::now);
927                let drained = (stdout_reader.is_finished() && stderr_reader.is_finished())
928                    || exited_at.elapsed() >= IO_DRAIN_TIMEOUT;
929                if drained && !input_writer.is_finished() && !group_killed {
930                    group_killed = true;
931                    if let Some(group) = &group {
932                        group.kill();
933                    }
934                }
935                if drained && input_writer.is_finished() {
936                    break status;
937                }
938            }
939            std::thread::sleep(Duration::from_millis(25));
940        };
941        let input_result = input_writer
942            .join()
943            .map_err(|_| anyhow::anyhow!("streamed command input writer panicked"))?;
944        Ok((status, input_result))
945    });
946    let deadline = Instant::now();
947    let stdout = stdout_reader.finish("stdout", deadline)?;
948    let stderr = stderr_reader.finish("stderr", deadline)?;
949    let (status, input_result) = process_result?;
950    drop(group);
951    if status.success() {
952        // A child that exited first explains the failure through its own
953        // status and stderr; the broken pipe that exit caused would only hide
954        // it. A successful child must not hide an input error.
955        input_result?;
956    }
957    let status = status.code().unwrap_or(-1);
958    Ok(finished_command(command, started, status, stdout, stderr))
959}
960
961#[derive(Clone)]
962pub struct CancellableProcessExecutor {
963    cancelled: Arc<AtomicBool>,
964    deadline: Option<Instant>,
965}
966
967/// Cancels synchronous executor work when its supervising async wait is dropped.
968pub struct ProcessCancellationGuard(Arc<AtomicBool>);
969
970impl Drop for ProcessCancellationGuard {
971    fn drop(&mut self) {
972        self.0.store(true, Ordering::Release);
973    }
974}
975
976impl CancellableProcessExecutor {
977    pub fn cancel_on_drop(&self) -> ProcessCancellationGuard {
978        ProcessCancellationGuard(self.cancelled.clone())
979    }
980
981    pub fn new(cancelled: Arc<AtomicBool>) -> Self {
982        Self {
983            cancelled,
984            deadline: None,
985        }
986    }
987
988    pub fn is_cancelled(&self) -> bool {
989        self.cancelled.load(Ordering::Acquire)
990            || self
991                .deadline
992                .is_some_and(|deadline| Instant::now() >= deadline)
993    }
994
995    pub fn with_timeout(timeout: Duration) -> Self {
996        Self {
997            cancelled: Arc::new(AtomicBool::new(false)),
998            deadline: Some(Instant::now() + timeout),
999        }
1000    }
1001
1002    /// Bounds an existing flag-based executor with a deadline, so a wedged
1003    /// child becomes a reported failure instead of running forever.
1004    pub fn with_deadline(mut self, timeout: Duration) -> Self {
1005        self.deadline = Some(Instant::now() + timeout);
1006        self
1007    }
1008
1009    fn check_cancelled(&self) -> Result<()> {
1010        if self.is_cancelled() {
1011            bail!("operation cancelled");
1012        }
1013        Ok(())
1014    }
1015}
1016
1017fn configured_command(command: &CommandSpec) -> Command {
1018    let mut process = Command::new(&command.program);
1019    if command.clear_env {
1020        process.env_clear();
1021    }
1022    if let Some(cwd) = &command.cwd {
1023        process.current_dir(cwd);
1024    }
1025    process.args(&command.args).envs(&command.env);
1026    process
1027}
1028
1029fn cancellable_command(command: &CommandSpec) -> Command {
1030    #[cfg(unix)]
1031    let mut process = configured_command(command);
1032    #[cfg(not(unix))]
1033    let process = configured_command(command);
1034    #[cfg(unix)]
1035    {
1036        use std::os::unix::process::CommandExt as _;
1037        process.process_group(0);
1038    }
1039    process
1040}
1041
1042/// How long a command's output may keep arriving after its leader exits.
1043/// A backgrounded descendant inherits the output pipes and can hold them open
1044/// for its whole life, although the command it was started by is finished.
1045/// The same bound as Codex's `IO_DRAIN_TIMEOUT_MS`. The owned process group is
1046/// terminated once this drain ends.
1047const IO_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
1048
1049/// A child pipe that can be waited on with a timeout, so its reader can be
1050/// stopped while a descendant still holds the write end.
1051trait PollablePipe: Read + Send + 'static {
1052    fn readable(&self, timeout: Duration) -> bool;
1053}
1054
1055macro_rules! pollable_pipe {
1056    ($pipe:ty) => {
1057        impl PollablePipe for $pipe {
1058            #[cfg(unix)]
1059            fn readable(&self, timeout: Duration) -> bool {
1060                use std::os::fd::AsRawFd as _;
1061                let mut poll = libc::pollfd {
1062                    fd: self.as_raw_fd(),
1063                    events: libc::POLLIN,
1064                    revents: 0,
1065                };
1066                let millis = i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX);
1067                // SAFETY: `poll` is one initialized pollfd for an open descriptor.
1068                unsafe { libc::poll(&raw mut poll, 1, millis) > 0 }
1069            }
1070
1071            // Without polling, a read blocks until the pipe closes.
1072            #[cfg(not(unix))]
1073            fn readable(&self, _timeout: Duration) -> bool {
1074                true
1075            }
1076        }
1077    };
1078}
1079
1080pollable_pipe!(std::process::ChildStdout);
1081pollable_pipe!(std::process::ChildStderr);
1082
1083/// Collects one child pipe on its own thread. Stopping it drops the thread's
1084/// end of the pipe, so a descendant that keeps the pipe open cannot hold the
1085/// reader past the drain.
1086struct PipeCollector {
1087    bytes: Arc<std::sync::Mutex<Vec<u8>>>,
1088    stop: Arc<AtomicBool>,
1089    thread: Option<std::thread::JoinHandle<std::io::Result<()>>>,
1090}
1091
1092impl PipeCollector {
1093    fn spawn(mut pipe: impl PollablePipe) -> Self {
1094        let bytes = Arc::new(std::sync::Mutex::new(Vec::new()));
1095        let stop = Arc::new(AtomicBool::new(false));
1096        let (collected, stopped) = (bytes.clone(), stop.clone());
1097        let thread = std::thread::spawn(move || {
1098            let mut chunk = [0_u8; 8192];
1099            while !stopped.load(Ordering::Acquire) {
1100                if !pipe.readable(Duration::from_millis(25)) {
1101                    continue;
1102                }
1103                match pipe.read(&mut chunk) {
1104                    Ok(0) => break,
1105                    Ok(count) => collected
1106                        .lock()
1107                        .unwrap_or_else(std::sync::PoisonError::into_inner)
1108                        .extend_from_slice(&chunk[..count]),
1109                    Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
1110                    Err(error) => return Err(error),
1111                }
1112            }
1113            Ok(())
1114        });
1115        Self {
1116            bytes,
1117            stop,
1118            thread: Some(thread),
1119        }
1120    }
1121
1122    fn is_finished(&self) -> bool {
1123        self.thread
1124            .as_ref()
1125            .is_none_or(std::thread::JoinHandle::is_finished)
1126    }
1127
1128    /// Waits for end of stream until `deadline`, then stops the reader and
1129    /// returns everything it collected.
1130    fn finish(mut self, stream: &str, deadline: Instant) -> Result<Vec<u8>> {
1131        while !self.is_finished() && Instant::now() < deadline {
1132            std::thread::sleep(Duration::from_millis(5));
1133        }
1134        self.stop.store(true, Ordering::Release);
1135        self.thread
1136            .take()
1137            .context("command reader already joined")?
1138            .join()
1139            .map_err(|_| anyhow::anyhow!("command {stream} reader panicked"))?
1140            .with_context(|| format!("read command {stream}"))?;
1141        let mut bytes = self
1142            .bytes
1143            .lock()
1144            .unwrap_or_else(std::sync::PoisonError::into_inner);
1145        Ok(std::mem::take(&mut bytes))
1146    }
1147}
1148
1149impl Drop for PipeCollector {
1150    fn drop(&mut self) {
1151        self.stop.store(true, Ordering::Release);
1152    }
1153}
1154
1155fn terminate_cancellable_child(child: &mut std::process::Child, preserve_children: bool) {
1156    #[cfg(unix)]
1157    if preserve_children {
1158        if let Err(error) = child.kill()
1159            && error.kind() != std::io::ErrorKind::NotFound
1160        {
1161            tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
1162        }
1163    } else if let Err(error) =
1164        crate::subprocess::signal_process_group(child.id() as i32, libc::SIGKILL)
1165    {
1166        // A group that is already gone is the wanted outcome, not a failure.
1167        tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command process group");
1168    }
1169    #[cfg(not(unix))]
1170    {
1171        let _ = preserve_children;
1172        if let Err(error) = child.kill() {
1173            tracing::warn!(pid = child.id(), %error, "could not terminate cancelled command");
1174        }
1175    }
1176    if let Err(error) = child.wait() {
1177        tracing::warn!(pid = child.id(), %error, "could not reap cancelled command");
1178    }
1179}
1180
1181impl CancellableProcessExecutor {
1182    /// One attempt, with no admission or retry of its own.
1183    fn run_once(&self, command: &CommandSpec) -> Result<CommandOutput> {
1184        if let Some(input) = &command.sensitive_stdin {
1185            let mut input = std::io::Cursor::new(input.0.as_slice());
1186            // Owned bytes, so each attempt gets its own reader.
1187            return stream_command_with_stdin(
1188                cancellable_command(command),
1189                command,
1190                &mut input,
1191                &|| self.is_cancelled(),
1192            );
1193        }
1194        let started = Instant::now();
1195        self.check_cancelled()?;
1196        let mut child = cancellable_command(command)
1197            .stdin(Stdio::null())
1198            .stdout(Stdio::piped())
1199            .stderr(Stdio::piped())
1200            .spawn()
1201            .with_context(|| format!("run {} for {}", command.program, command.purpose))?;
1202        let group = (!command.detaches && !command.preserve_children_on_cancel)
1203            .then(|| crate::subprocess::ProcessGroupGuard::new(Some(child.id())));
1204        let stdout = child.stdout.take().context("command stdout missing")?;
1205        let stderr = child.stderr.take().context("command stderr missing")?;
1206        let stdout_reader = PipeCollector::spawn(stdout);
1207        let stderr_reader = PipeCollector::spawn(stderr);
1208        let mut status = None;
1209        let mut exited_at = None;
1210        let status = loop {
1211            if self.is_cancelled() {
1212                terminate_cancellable_child(&mut child, command.preserve_children_on_cancel);
1213                let deadline = Instant::now() + IO_DRAIN_TIMEOUT;
1214                for (stream, reader) in [("stdout", stdout_reader), ("stderr", stderr_reader)] {
1215                    if let Err(error) = reader.finish(stream, deadline) {
1216                        tracing::warn!(stream, %error, "cancelled command reader failed");
1217                    }
1218                }
1219                bail!("operation cancelled while {}", command.purpose);
1220            }
1221            if status.is_none() {
1222                status = child
1223                    .try_wait()
1224                    .with_context(|| format!("wait for {}", command.purpose))?;
1225            }
1226            // The command completes when its leader exits. Descendants can
1227            // retain the pipes, so output is collected for a bounded time.
1228            if let Some(status) = status {
1229                let exited_at = *exited_at.get_or_insert_with(Instant::now);
1230                if (stdout_reader.is_finished() && stderr_reader.is_finished())
1231                    || exited_at.elapsed() >= IO_DRAIN_TIMEOUT
1232                {
1233                    break status;
1234                }
1235            }
1236            std::thread::sleep(Duration::from_millis(25));
1237        };
1238        let deadline = Instant::now();
1239        let stdout = stdout_reader.finish("stdout", deadline)?;
1240        let stderr = stderr_reader.finish("stderr", deadline)?;
1241        let status = status.code().unwrap_or(-1);
1242        drop(group);
1243        Ok(finished_command(command, started, status, stdout, stderr))
1244    }
1245}
1246
1247impl CommandExecutor for CancellableProcessExecutor {
1248    fn execute_cleanup(&self, command: &CommandSpec) -> Result<CommandOutput> {
1249        self.clone()
1250            .with_deadline(Duration::from_secs(15))
1251            .execute(command)
1252    }
1253
1254    fn execute_cleanup_with_timeout(
1255        &self,
1256        command: &CommandSpec,
1257        timeout: Duration,
1258    ) -> Result<CommandOutput> {
1259        self.clone().with_deadline(timeout).execute(command)
1260    }
1261
1262    fn cancellation_requested(&self) -> bool {
1263        self.is_cancelled()
1264    }
1265
1266    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1267        crate::runtime::off_async_worker(|| {
1268            let _running = BlockingOperation::command(command);
1269            with_ssh_admission(command, self, &|| self.is_cancelled(), |command| {
1270                self.run_once(command)
1271            })
1272        })
1273    }
1274
1275    fn execute_with_stdin(
1276        &self,
1277        command: &CommandSpec,
1278        input: &mut (dyn Read + Send),
1279    ) -> Result<CommandOutput> {
1280        crate::runtime::off_async_worker(|| {
1281            let _running = BlockingOperation::command(command);
1282            // A caller's stream cannot be replayed, so this path takes a
1283            // session and a permit but never retries.
1284            let session = command.open_ssh_session(self)?;
1285            let _permit = command
1286                .ssh_destination
1287                .as_deref()
1288                .map(|destination| {
1289                    SshAdmission::acquire_unless(destination, &|| self.is_cancelled())
1290                })
1291                .transpose()?;
1292            let command = session.command();
1293            // The child runs in its own process group so cancellation can kill
1294            // the whole group, which is what releases a writer blocked on a
1295            // full pipe.
1296            stream_command_with_stdin(cancellable_command(command), command, input, &|| {
1297                self.is_cancelled()
1298            })
1299        })
1300    }
1301}
1302
1303/// A command supervised by [`BoundedProcessExecutor`] did not finish before
1304/// its deadline. Callers downcast to this to tell a hung probe apart from a
1305/// command that could not be started at all.
1306#[derive(Debug, Clone, PartialEq, Eq)]
1307pub struct CommandTimedOut {
1308    pub program: String,
1309    pub purpose: String,
1310    pub timeout: Duration,
1311}
1312
1313impl std::fmt::Display for CommandTimedOut {
1314    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1315        write!(
1316            formatter,
1317            "`{}` did not answer within {} seconds while trying to {}",
1318            self.program,
1319            self.timeout.as_secs(),
1320            self.purpose
1321        )
1322    }
1323}
1324
1325impl std::error::Error for CommandTimedOut {}
1326
1327/// Runs every command with its own deadline.
1328///
1329/// [`CancellableProcessExecutor::with_timeout`] bounds a whole operation from a
1330/// single shared deadline, which suits one provisioning run. Prerequisite
1331/// probes are different: each one is expected to answer quickly, and a wedged
1332/// socket or blackholed network must not stall the probes that follow it. A
1333/// timeout here names the probe that hung, so the caller can report it the same
1334/// way it reports any other probe failure.
1335#[derive(Debug, Clone, Copy)]
1336pub struct BoundedProcessExecutor {
1337    timeout: Duration,
1338}
1339
1340impl BoundedProcessExecutor {
1341    pub const fn new(timeout: Duration) -> Self {
1342        Self { timeout }
1343    }
1344}
1345
1346impl CommandExecutor for BoundedProcessExecutor {
1347    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1348        let executor = CancellableProcessExecutor::with_timeout(self.timeout);
1349        executor.execute(command).map_err(|error| {
1350            if executor.is_cancelled() {
1351                anyhow::Error::new(CommandTimedOut {
1352                    program: command.program.clone(),
1353                    purpose: command.purpose.clone(),
1354                    timeout: self.timeout,
1355                })
1356            } else {
1357                error
1358            }
1359        })
1360    }
1361
1362    fn execute_cleanup_with_timeout(
1363        &self,
1364        command: &CommandSpec,
1365        timeout: Duration,
1366    ) -> Result<CommandOutput> {
1367        // This executor's configured timeout is a hard per-command cap; the
1368        // call-specific cleanup timeout can only make the attempt shorter.
1369        CancellableProcessExecutor::with_timeout(self.timeout.min(timeout)).execute(command)
1370    }
1371
1372    fn execute_with_stdin(
1373        &self,
1374        command: &CommandSpec,
1375        input: &mut (dyn Read + Send),
1376    ) -> Result<CommandOutput> {
1377        CancellableProcessExecutor::with_timeout(self.timeout).execute_with_stdin(command, input)
1378    }
1379}
1380
1381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1382pub struct CommandPlan {
1383    pub description: String,
1384    pub commands: Vec<CommandSpec>,
1385}
1386
1387impl CommandPlan {
1388    /// Supply one container environment value without placing it in the
1389    /// Podman/SSH argument vector. The target launcher reads the value from
1390    /// stdin, exports it, and asks the container engine to inherit it by name.
1391    pub fn provide_target_environment_secret(
1392        &mut self,
1393        target: &TargetTemplate,
1394        name: &str,
1395        value: &str,
1396    ) -> Result<()> {
1397        ensure!(
1398            !name.is_empty()
1399                && name.bytes().enumerate().all(|(index, byte)| byte == b'_'
1400                    || byte.is_ascii_alphabetic()
1401                    || (index > 0 && byte.is_ascii_digit())),
1402            "invalid secret environment variable name"
1403        );
1404        ensure!(
1405            !value.as_bytes().contains(&b'\n') && !value.as_bytes().contains(&b'\r'),
1406            "secret environment value cannot contain a newline"
1407        );
1408        let command = self
1409            .commands
1410            .iter_mut()
1411            .find(|command| command.creates_target)
1412            .context("provisioning plan has no target creation command")?;
1413        let read_and_export = format!("IFS= read -r {name} || exit 1; export {name};");
1414        match target {
1415            TargetTemplate::LocalPodman(_)
1416            | TargetTemplate::LocalDocker(_)
1417            | TargetTemplate::AppleContainer(_) => {
1418                let program = std::mem::replace(&mut command.program, "sh".to_owned());
1419                let args = std::mem::take(&mut command.args);
1420                command.args = vec![
1421                    "-c".to_owned(),
1422                    format!("{read_and_export} exec \"$@\""),
1423                    "mj-secret-env".to_owned(),
1424                    program,
1425                ];
1426                command.args.extend(args);
1427            }
1428            TargetTemplate::SshPodman { .. } | TargetTemplate::SshDocker { .. } => {
1429                let remote = command
1430                    .args
1431                    .last_mut()
1432                    .context("remote container command has no SSH command argument")?;
1433                *remote = format!("{read_and_export} exec {remote}");
1434            }
1435            TargetTemplate::LocalBare
1436            | TargetTemplate::AwsEc2(_)
1437            | TargetTemplate::SshBare { .. } => {
1438                bail!("target does not support inherited container environment")
1439            }
1440        }
1441        let mut input = value.as_bytes().to_vec();
1442        input.push(b'\n');
1443        command.sensitive_stdin = Some(SensitiveCommandInput(input));
1444        Ok(())
1445    }
1446
1447    pub fn execute(&self, executor: &impl CommandExecutor) -> Result<Vec<CommandOutput>> {
1448        let mut outputs = Vec::with_capacity(self.commands.len());
1449        for command in &self.commands {
1450            let output = executor.execute(command)?;
1451            if output.status != 0 {
1452                bail!(
1453                    "{} failed with status {}: {}",
1454                    command.purpose,
1455                    output.status,
1456                    String::from_utf8_lossy(&output.stderr)
1457                );
1458            }
1459            outputs.push(output);
1460        }
1461        Ok(outputs)
1462    }
1463
1464    /// Execute the plan the same way [`Self::execute`] does, except that
1465    /// commands sharing a [`CommandSpec::parallel_group`] marker and
1466    /// appearing consecutively in `commands` run concurrently as one batch.
1467    ///
1468    /// A batch starts only once every earlier command has succeeded, and a
1469    /// batch that fails reports the first failure in plan order regardless
1470    /// of which command finished first — the same fail-fast contract
1471    /// [`Self::execute`] provides between individual commands. This method
1472    /// requires a `Sync` executor because a batch shares it across threads;
1473    /// [`Self::execute`] keeps working with non-`Sync` executors such as
1474    /// test fakes built on `RefCell`.
1475    pub fn execute_concurrent(
1476        &self,
1477        executor: &(impl CommandExecutor + Sync),
1478    ) -> Result<Vec<CommandOutput>> {
1479        let mut outputs = Vec::with_capacity(self.commands.len());
1480        let mut index = 0;
1481        while index < self.commands.len() {
1482            let group = self.commands[index].parallel_group;
1483            let mut end = index + 1;
1484            if group.is_some() {
1485                while end < self.commands.len() && self.commands[end].parallel_group == group {
1486                    end += 1;
1487                }
1488            }
1489            let batch = &self.commands[index..end];
1490            if let [command] = batch {
1491                outputs.push(checked_command_output(command, executor.execute(command)?)?);
1492            } else {
1493                let results: Vec<Result<CommandOutput>> = std::thread::scope(|scope| {
1494                    let handles: Vec<_> = batch
1495                        .iter()
1496                        .map(|command| scope.spawn(|| executor.execute(command)))
1497                        .collect();
1498                    handles
1499                        .into_iter()
1500                        .map(|handle| match handle.join() {
1501                            Ok(result) => result,
1502                            Err(panic) => Err(anyhow::anyhow!(
1503                                "concurrent command thread panicked: {}",
1504                                command_thread_panic_message(panic.as_ref())
1505                            )),
1506                        })
1507                        .collect()
1508                });
1509                for (command, result) in batch.iter().zip(results) {
1510                    outputs.push(checked_command_output(command, result?)?);
1511                }
1512            }
1513            index = end;
1514        }
1515        Ok(outputs)
1516    }
1517
1518    /// Split the plan around the command that creates the session's target:
1519    /// the commands through that one, then the commands that run against a
1520    /// target which already exists.
1521    ///
1522    /// A plan that creates nothing — an existing project directory, say —
1523    /// splits into nothing, so a caller never arms a teardown for a target it
1524    /// did not bring into existence.
1525    pub fn split_at_target_creation(&self) -> Option<(Self, Self)> {
1526        let created = self
1527            .commands
1528            .iter()
1529            .position(|command| command.creates_target)?;
1530        let (creation, remainder) = self.commands.split_at(created + 1);
1531        Some((
1532            Self {
1533                description: self.description.clone(),
1534                commands: creation.to_vec(),
1535            },
1536            Self {
1537                description: self.description.clone(),
1538                commands: remainder.to_vec(),
1539            },
1540        ))
1541    }
1542}
1543
1544/// Fail the same way [`CommandPlan::execute`] does for a non-zero exit
1545/// status; kept as a shared helper so [`CommandPlan::execute_concurrent`]
1546/// reports identical error text.
1547pub fn checked_command_output(
1548    command: &CommandSpec,
1549    output: CommandOutput,
1550) -> Result<CommandOutput> {
1551    if output.status != 0 {
1552        bail!(
1553            "{} failed with status {}: {}",
1554            command.purpose,
1555            output.status,
1556            String::from_utf8_lossy(&output.stderr)
1557        );
1558    }
1559    Ok(output)
1560}
1561
1562/// Describe a spawned command thread's panic payload for error context.
1563pub fn command_thread_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
1564    if let Some(message) = payload.downcast_ref::<&str>() {
1565        (*message).to_owned()
1566    } else if let Some(message) = payload.downcast_ref::<String>() {
1567        message.clone()
1568    } else {
1569        "non-string panic payload".to_owned()
1570    }
1571}
1572
1573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1574pub struct RepositorySpec {
1575    /// Network clone URL. Managed workspaces require a configured remote.
1576    pub url: Option<String>,
1577    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1578    pub push_urls: Vec<String>,
1579    pub destination: String,
1580    pub git_ref: Option<String>,
1581    /// Read-only bare repository mounted into the target for Git object reuse.
1582    /// A missing or unusable reference is only an optimization miss.
1583    #[serde(default, skip_serializing_if = "Option::is_none")]
1584    pub reference: Option<String>,
1585}
1586
1587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1588pub struct ProjectBundleSpec {
1589    pub primary: String,
1590    pub repositories: Vec<RepositorySpec>,
1591}
1592
1593impl ProjectBundleSpec {
1594    pub fn validate(&self) -> Result<()> {
1595        validate_relative_path(&self.primary)?;
1596        if self.repositories.is_empty() {
1597            bail!("a project bundle must contain at least one repository");
1598        }
1599        let mut destinations = std::collections::BTreeSet::new();
1600        for repository in &self.repositories {
1601            validate_relative_path(&repository.destination)?;
1602            ensure!(
1603                repository
1604                    .url
1605                    .as_deref()
1606                    .is_some_and(|url| !url.trim().is_empty() && !url.starts_with('-')),
1607                "isolated repositories require a network Git remote; configure a remote or use a raw local session"
1608            );
1609            crate::remote_git::validate_network_url(
1610                repository.url.as_deref().expect("checked above"),
1611            )?;
1612            for push_url in &repository.push_urls {
1613                crate::remote_git::validate_network_url(push_url)?;
1614            }
1615            ensure!(
1616                repository.git_ref.is_none(),
1617                "git_ref is no longer supported; remove it to start from the remote's default branch"
1618            );
1619            if !destinations.insert(&repository.destination) {
1620                bail!(
1621                    "duplicate repository destination {}",
1622                    repository.destination
1623                );
1624            }
1625        }
1626        if !destinations.contains(&self.primary) {
1627            bail!("primary repository is not present in the bundle");
1628        }
1629        Ok(())
1630    }
1631}
1632
1633#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1634#[serde(tag = "kind", rename_all = "snake_case")]
1635pub enum PodmanWorkspaceStorage {
1636    PodmanVolume,
1637    HostHelper {
1638        root: String,
1639        helper: Vec<String>,
1640    },
1641    #[default]
1642    ContainerLayer,
1643}
1644
1645#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1646pub struct ContainerTemplate {
1647    pub image: String,
1648    #[serde(default)]
1649    pub pull_policy: ImagePullPolicy,
1650    #[serde(default)]
1651    pub extra_run_args: Vec<String>,
1652    #[serde(default)]
1653    pub workspace_storage: PodmanWorkspaceStorage,
1654    /// Per-target mbx build cache overrides, carried from the user
1655    /// configuration so cache resolution can read them off a runtime target.
1656    #[serde(default)]
1657    pub build_cache: Option<crate::config::TargetBuildCache>,
1658}
1659
1660impl ImagePullPolicy {
1661    /// How fresh this target wants its image, with `Auto` read from the image
1662    /// reference. This is the freshness the background refresher acts on.
1663    pub fn resolve(self, image: &str) -> Self {
1664        if self != Self::Auto {
1665            return self;
1666        }
1667        if image_is_digest_pinned(image) {
1668            Self::Missing
1669        } else if image_is_remote(image) && image_uses_latest_tag(image) {
1670            Self::Newer
1671        } else {
1672            Self::Missing
1673        }
1674    }
1675
1676    /// How fresh a launch insists on being. `Auto` never pulls here: the daemon
1677    /// refreshes remote `:latest` images on its own schedule, so a session
1678    /// starts from the cached image instead of blocking a launch on a
1679    /// multi-gigabyte download. An explicit policy still means what it says.
1680    pub fn at_launch(self, image: &str) -> Self {
1681        if self == Self::Auto {
1682            Self::Missing
1683        } else {
1684            self.resolve(image)
1685        }
1686    }
1687
1688    /// What this policy does to `image`, in the words a settings screen can
1689    /// show. `Auto` is derived rather than an alias, so it names the launch
1690    /// behavior and the background refresh it implies, from the same image
1691    /// reading `resolve` uses.
1692    pub fn describe(self, image: &str) -> &'static str {
1693        match self {
1694            Self::Always => "Pull every launch",
1695            Self::Newer => "Pull when the registry is newer",
1696            Self::Missing => "Pull only if missing",
1697            Self::Never => "Never pull",
1698            Self::Auto => match self.resolve(image) {
1699                Self::Newer => "Pull if missing at launch; refresh :latest in background",
1700                _ => "Pull if missing",
1701            },
1702        }
1703    }
1704
1705    /// Podman's spelling of an already-resolved policy.
1706    pub fn podman_value(self) -> &'static str {
1707        match self {
1708            Self::Always => "always",
1709            Self::Newer => "newer",
1710            Self::Missing => "missing",
1711            Self::Never => "never",
1712            Self::Auto => unreachable!("auto pull policy must resolve"),
1713        }
1714    }
1715}
1716
1717/// Where a background image refresh runs.
1718///
1719/// The SSH form wraps commands the way provisioning does rather than the way
1720/// the preflight probes do: a pull runs for minutes, and the probes' two-second
1721/// keepalive would drop the connection underneath it.
1722#[derive(Debug, Clone, PartialEq, Eq)]
1723pub enum ImageHost {
1724    LocalPodman,
1725    LocalDocker,
1726    AppleContainer,
1727    SshPodman(SshTarget),
1728    SshDocker(SshTarget),
1729}
1730
1731impl ImageHost {
1732    pub const fn engine(&self) -> &'static str {
1733        match self {
1734            Self::LocalPodman | Self::SshPodman(_) => "podman",
1735            Self::LocalDocker | Self::SshDocker(_) => "docker",
1736            Self::AppleContainer => "container",
1737        }
1738    }
1739
1740    /// How this host is named in a log line.
1741    pub fn label(&self) -> String {
1742        match self {
1743            Self::LocalPodman => "local podman".to_owned(),
1744            Self::LocalDocker => "local docker".to_owned(),
1745            Self::AppleContainer => "apple container".to_owned(),
1746            Self::SshPodman(ssh) => format!("podman on {}", ssh.destination),
1747            Self::SshDocker(ssh) => format!("docker on {}", ssh.destination),
1748        }
1749    }
1750
1751    fn command(&self, args: Vec<String>, purpose: String) -> CommandSpec {
1752        match self {
1753            Self::LocalPodman | Self::LocalDocker | Self::AppleContainer => {
1754                CommandSpec::new(args[0].clone(), args[1..].iter().cloned())
1755            }
1756            Self::SshPodman(ssh) | Self::SshDocker(ssh) => ssh_command_owned(ssh, args),
1757        }
1758        .purpose(purpose)
1759    }
1760}
1761
1762/// When a background refresh downloads an image.
1763///
1764/// A host that already has the image is the common case, and most targets
1765/// only need the copy to exist. Ordering matters: merging two targets that
1766/// share an image takes the larger value, so one demanding target upgrades
1767/// the pair.
1768#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1769pub enum RefreshWhen {
1770    /// Pull only when the host has no copy (missing, or auto on a fixed tag).
1771    WhenAbsent,
1772    /// Pull every tick (always, or newer / auto on a moving tag).
1773    Always,
1774}
1775
1776/// The commands that keep one host's copy of one image current, away from any
1777/// session launch. They run in this order, and only for that host.
1778#[derive(Debug, Clone, PartialEq, Eq)]
1779pub struct ImageRefresh {
1780    pub host: ImageHost,
1781    pub image: String,
1782    pub platform: Option<String>,
1783    /// Whether this image is downloaded only when the host lacks it, or on
1784    /// every refresh.
1785    pub when: RefreshWhen,
1786    /// Reads the cached image id, so a pull that changed nothing stays quiet.
1787    /// Run before and after the pull.
1788    pub image_id: CommandSpec,
1789    pub pull: CommandSpec,
1790    /// Dangling images only. Both engines keep an image a container still uses.
1791    /// `None` for Apple's `container` engine, which has no verified prune form.
1792    pub prune: Option<CommandSpec>,
1793}
1794
1795/// The background refresh for one configured container target, or `None` when
1796/// the target asked never to download the image in the background.
1797///
1798/// `never` is the only opt-out. Every other policy at least wants the host to
1799/// have a copy, so the daemon downloads a missing image once; `always` and
1800/// `newer` keep the hourly pull they always had.
1801pub fn image_refresh(
1802    host: ImageHost,
1803    image: &str,
1804    platform: Option<&str>,
1805    pull_policy: ImagePullPolicy,
1806) -> Option<ImageRefresh> {
1807    let when = match pull_policy.resolve(image) {
1808        ImagePullPolicy::Always | ImagePullPolicy::Newer => RefreshWhen::Always,
1809        ImagePullPolicy::Missing => RefreshWhen::WhenAbsent,
1810        ImagePullPolicy::Never => return None,
1811        ImagePullPolicy::Auto => unreachable!("auto pull policy must resolve"),
1812    };
1813    let engine = host.engine();
1814    // Apple's `container` CLI has no `--format` on `image inspect`: the exit
1815    // status is what says the image is present, and the printed description
1816    // stands in for the id when comparing before and after a pull.
1817    let apple = matches!(host, ImageHost::AppleContainer);
1818    let mut image_id_args = vec![engine.to_owned(), "image".to_owned(), "inspect".to_owned()];
1819    if !apple {
1820        image_id_args.push("--format".to_owned());
1821        image_id_args.push("{{.Id}}".to_owned());
1822    }
1823    image_id_args.push(image.to_owned());
1824    let image_id = host.command(
1825        image_id_args,
1826        format!("read the cached id of container image {image}"),
1827    );
1828    let mut pull_args = vec![engine.to_owned()];
1829    if apple {
1830        pull_args.push("image".to_owned());
1831    }
1832    pull_args.push("pull".to_owned());
1833    // Apple's engine runs only native images, so it takes no platform.
1834    if let Some(platform) = platform.filter(|_| !apple) {
1835        pull_args.push(format!("--platform={platform}"));
1836    }
1837    pull_args.push(image.to_owned());
1838    let pull = host.command(pull_args, format!("refresh container image {image}"));
1839    let prune = (!apple).then(|| {
1840        host.command(
1841            vec![
1842                engine.to_owned(),
1843                "image".to_owned(),
1844                "prune".to_owned(),
1845                "-f".to_owned(),
1846            ],
1847            "remove dangling container images".to_owned(),
1848        )
1849    });
1850    Some(ImageRefresh {
1851        host,
1852        image: image.to_owned(),
1853        platform: platform.map(str::to_owned),
1854        when,
1855        image_id,
1856        pull,
1857        prune,
1858    })
1859}
1860
1861fn image_is_digest_pinned(image: &str) -> bool {
1862    image
1863        .rsplit_once('@')
1864        .is_some_and(|(_, digest)| !digest.is_empty())
1865}
1866
1867fn image_is_remote(image: &str) -> bool {
1868    !image.starts_with("localhost/") && !image.starts_with("local/")
1869}
1870
1871fn image_uses_latest_tag(image: &str) -> bool {
1872    let name = image.split_once('@').map_or(image, |(name, _)| name);
1873    let final_component = name.rsplit('/').next().unwrap_or(name);
1874    !final_component.contains(':') || final_component.ends_with(":latest")
1875}
1876
1877#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1878pub struct SshTarget {
1879    pub destination: String,
1880    #[serde(default)]
1881    pub ssh_args: Vec<String>,
1882}
1883
1884#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1885pub struct AwsTemplate {
1886    pub profile: String,
1887    pub region: String,
1888    pub launch_template: String,
1889    pub launch_template_version: Option<String>,
1890    pub instance_type: Option<String>,
1891    pub ssh: SshTarget,
1892}
1893
1894#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1895#[serde(tag = "kind", rename_all = "snake_case")]
1896pub enum TargetTemplate {
1897    LocalBare,
1898    LocalPodman(ContainerTemplate),
1899    LocalDocker(ContainerTemplate),
1900    AppleContainer(ContainerTemplate),
1901    AwsEc2(AwsTemplate),
1902    SshBare {
1903        ssh: SshTarget,
1904        #[serde(default = "default_ssh_prefix")]
1905        workspace_prefix: String,
1906    },
1907    SshPodman {
1908        ssh: SshTarget,
1909        container: ContainerTemplate,
1910    },
1911    SshDocker {
1912        ssh: SshTarget,
1913        container: ContainerTemplate,
1914    },
1915}
1916
1917fn default_ssh_prefix() -> String {
1918    ".local/share/hel/workspaces".to_owned()
1919}
1920
1921#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1922#[serde(tag = "kind", rename_all = "snake_case")]
1923pub enum PodmanWorkspaceLocator {
1924    #[default]
1925    ContainerLayer,
1926    Volume {
1927        name: String,
1928    },
1929    HostPath {
1930        path: String,
1931        helper: Vec<String>,
1932        resource: String,
1933    },
1934}
1935
1936#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1937#[serde(tag = "kind", rename_all = "snake_case")]
1938pub enum TargetLocator {
1939    LocalBare {
1940        worker_root: String,
1941    },
1942    LocalPodman {
1943        container_id: String,
1944        #[serde(default)]
1945        workspace_storage: PodmanWorkspaceLocator,
1946        /// The session that owns the container when this locator is a
1947        /// sub-agent child borrowing its parent's container; `None` when the
1948        /// session owns the container itself.
1949        #[serde(default, skip_serializing_if = "Option::is_none")]
1950        borrowed_from: Option<String>,
1951    },
1952    LocalDocker {
1953        container_id: String,
1954        /// The session that owns the container when this locator is a
1955        /// sub-agent child borrowing its parent's container; `None` when the
1956        /// session owns the container itself.
1957        #[serde(default, skip_serializing_if = "Option::is_none")]
1958        borrowed_from: Option<String>,
1959    },
1960    AppleContainer {
1961        container_id: String,
1962        /// The session that owns the container when this locator is a
1963        /// sub-agent child borrowing its parent's container; `None` when the
1964        /// session owns the container itself.
1965        #[serde(default, skip_serializing_if = "Option::is_none")]
1966        borrowed_from: Option<String>,
1967    },
1968    AwsEc2 {
1969        profile: String,
1970        region: String,
1971        instance_id: String,
1972        ssh: SshTarget,
1973        workspace: String,
1974    },
1975    SshBare {
1976        ssh: SshTarget,
1977        workspace: String,
1978        /// A borrowed-target child has its own worker identity in the parent's workspace.
1979        #[serde(default, skip_serializing_if = "Option::is_none")]
1980        worker_id: Option<String>,
1981    },
1982    SshPodman {
1983        ssh: SshTarget,
1984        container_id: String,
1985        #[serde(default)]
1986        workspace_storage: PodmanWorkspaceLocator,
1987        /// The session that owns the container when this locator is a
1988        /// sub-agent child borrowing its parent's container; `None` when the
1989        /// session owns the container itself.
1990        #[serde(default, skip_serializing_if = "Option::is_none")]
1991        borrowed_from: Option<String>,
1992    },
1993    SshDocker {
1994        ssh: SshTarget,
1995        container_id: String,
1996        /// The session that owns the container when this locator is a
1997        /// sub-agent child borrowing its parent's container; `None` when the
1998        /// session owns the container itself.
1999        #[serde(default, skip_serializing_if = "Option::is_none")]
2000        borrowed_from: Option<String>,
2001    },
2002}
2003
2004impl TargetTemplate {
2005    pub const fn container_engine(&self) -> Option<&'static str> {
2006        match self {
2007            Self::LocalPodman(_) | Self::SshPodman { .. } => Some("podman"),
2008            Self::LocalDocker(_) | Self::SshDocker { .. } => Some("docker"),
2009            Self::AppleContainer(_) => Some("container"),
2010            _ => None,
2011        }
2012    }
2013
2014    /// The host that downloads this target's image, with the container
2015    /// settings that name the image. `None` for targets that run no image.
2016    pub fn image_host(&self) -> Option<(ImageHost, &ContainerTemplate)> {
2017        match self {
2018            Self::LocalPodman(container) => Some((ImageHost::LocalPodman, container)),
2019            Self::LocalDocker(container) => Some((ImageHost::LocalDocker, container)),
2020            Self::AppleContainer(container) => Some((ImageHost::AppleContainer, container)),
2021            Self::SshPodman { ssh, container } => {
2022                Some((ImageHost::SshPodman(ssh.clone()), container))
2023            }
2024            Self::SshDocker { ssh, container } => {
2025                Some((ImageHost::SshDocker(ssh.clone()), container))
2026            }
2027            Self::LocalBare | Self::AwsEc2(_) | Self::SshBare { .. } => None,
2028        }
2029    }
2030}
2031
2032impl TargetLocator {
2033    /// The target kind spelling shared with [`crate::config::TargetTemplate`].
2034    pub const fn kind_name(&self) -> &'static str {
2035        match self {
2036            Self::LocalBare { .. } => "local-bare",
2037            Self::LocalPodman { .. } => "local-podman",
2038            Self::LocalDocker { .. } => "local-docker",
2039            Self::AppleContainer { .. } => "apple-container",
2040            Self::AwsEc2 { .. } => "aws-ec2",
2041            Self::SshBare { .. } => "ssh-bare",
2042            Self::SshPodman { .. } => "ssh-podman",
2043            Self::SshDocker { .. } => "ssh-docker",
2044        }
2045    }
2046
2047    pub const fn container_engine(&self) -> Option<&'static str> {
2048        match self {
2049            Self::LocalPodman { .. } | Self::SshPodman { .. } => Some("podman"),
2050            Self::LocalDocker { .. } | Self::SshDocker { .. } => Some("docker"),
2051            Self::AppleContainer { .. } => Some("container"),
2052            _ => None,
2053        }
2054    }
2055}
2056
2057/// Commands and identity needed to bring a stopped managed target back online.
2058/// Only runtimes whose stopped resources retain their durable files provide
2059/// one; callers leave every other target kind alone.
2060#[derive(Debug, Clone, PartialEq, Eq)]
2061pub struct TargetRecoveryPlan {
2062    pub exists: CommandSpec,
2063    pub inspect: CommandSpec,
2064    pub start: CommandSpec,
2065    pub session_id: String,
2066}
2067
2068#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2069pub enum TargetRecoveryOutcome {
2070    NotRequired,
2071    Missing,
2072    AlreadyRunning,
2073    Started,
2074}
2075
2076pub fn resource_name(session_id: &str) -> Result<String> {
2077    validate_session_id(session_id)?;
2078    let readable: String = session_id
2079        .chars()
2080        .filter(|character| character.is_ascii_alphanumeric())
2081        .take(12)
2082        .map(|character| character.to_ascii_lowercase())
2083        .collect();
2084    let digest = Sha256::digest(session_id.as_bytes());
2085    Ok(format!(
2086        "mj-{readable}-{:02x}{:02x}{:02x}",
2087        digest[0], digest[1], digest[2]
2088    ))
2089}
2090
2091/// Resource generations let a Move retain its source while provisioning the destination.
2092pub fn move_resource_name(session_id: &str, operation_id: &str) -> Result<String> {
2093    let digest = Sha256::digest(operation_id.as_bytes());
2094    Ok(format!(
2095        "{}-move-{}",
2096        resource_name(session_id)?,
2097        crate::hex::lower_hex(&digest[..8])
2098    ))
2099}
2100
2101pub fn resource_name_belongs_to(name: &str, session_id: &str) -> Result<bool> {
2102    let base = resource_name(session_id)?;
2103    Ok(name == base
2104        || name
2105            .strip_prefix(&format!("{base}-move-"))
2106            .is_some_and(|suffix| {
2107                suffix.len() == 16 && suffix.bytes().all(|b| b.is_ascii_hexdigit())
2108            }))
2109}
2110
2111pub fn podman_workspace_locator(
2112    template: &ContainerTemplate,
2113    session_id: &str,
2114) -> Result<PodmanWorkspaceLocator> {
2115    podman_workspace_locator_named(template, &resource_name(session_id)?)
2116}
2117
2118pub fn podman_workspace_locator_named(
2119    template: &ContainerTemplate,
2120    name: &str,
2121) -> Result<PodmanWorkspaceLocator> {
2122    let resource = format!("{name}-workspace");
2123    match &template.workspace_storage {
2124        PodmanWorkspaceStorage::PodmanVolume => {
2125            Ok(PodmanWorkspaceLocator::Volume { name: resource })
2126        }
2127        PodmanWorkspaceStorage::HostHelper { root, helper } => {
2128            let root = Path::new(root);
2129            ensure!(
2130                crate::target_path::is_absolute(root),
2131                "Podman workspace storage root must be absolute"
2132            );
2133            ensure!(
2134                !helper.is_empty() && helper.iter().all(|argument| !argument.is_empty()),
2135                "Podman workspace storage helper must contain non-empty arguments"
2136            );
2137            Ok(PodmanWorkspaceLocator::HostPath {
2138                path: root.join(&resource).to_string_lossy().into_owned(),
2139                helper: helper.clone(),
2140                resource,
2141            })
2142        }
2143        PodmanWorkspaceStorage::ContainerLayer => Ok(PodmanWorkspaceLocator::ContainerLayer),
2144    }
2145}
2146
2147/// The in-container workspace root a session's repositories live under.
2148///
2149/// `recorded` is the session record's `container_workspace`. A session whose
2150/// container predates per-session workspaces has none and keeps the legacy
2151/// shared `/workspace`; every session created since records
2152/// `/workspace/<session id>`, so a build cache shared by every container on a
2153/// host never sees two checkouts of one project at the same absolute path.
2154pub fn container_workspace_root(recorded: Option<&Path>) -> String {
2155    recorded.map_or_else(
2156        || CONTAINER_WORKSPACE.to_owned(),
2157        |path| path.to_string_lossy().into_owned(),
2158    )
2159}
2160
2161/// The per-session container workspace recorded for a session created now.
2162pub fn new_container_workspace(session_id: &str) -> Result<PathBuf> {
2163    validate_session_id(session_id)?;
2164    Ok(Path::new(CONTAINER_WORKSPACE).join(session_id))
2165}
2166
2167/// The workspace directory an EC2 session owns in its login home.
2168///
2169/// EC2 instances are provisioned per session, so the path is decided by the
2170/// session id alone and is the same whether it is read from a target template
2171/// or rebuilt from a stored locator.
2172pub fn aws_workspace(session_id: &str) -> String {
2173    format!(".local/share/hel/workspaces/{session_id}")
2174}
2175
2176pub fn workspace_for(template: &TargetTemplate, session_id: &str) -> Result<String> {
2177    validate_session_id(session_id)?;
2178    match template {
2179        TargetTemplate::LocalBare => bail!("local bare projects use their selected directory"),
2180        // A container workspace is per session and recorded on the session
2181        // record, so it is read with `container_workspace_root` instead.
2182        TargetTemplate::LocalPodman(_)
2183        | TargetTemplate::LocalDocker(_)
2184        | TargetTemplate::AppleContainer(_)
2185        | TargetTemplate::SshPodman { .. }
2186        | TargetTemplate::SshDocker { .. } => {
2187            bail!("container targets use the session's recorded container workspace")
2188        }
2189        TargetTemplate::AwsEc2(_) => Ok(aws_workspace(session_id)),
2190        TargetTemplate::SshBare {
2191            workspace_prefix, ..
2192        } => {
2193            validate_workspace_prefix(workspace_prefix)?;
2194            // Interpret a leading "~/" as home-relative. Remote commands are
2195            // single-quoted, so a literal tilde would name a directory called
2196            // "~"; a relative path resolves against the login home for ssh
2197            // and scp alike.
2198            let prefix = workspace_prefix
2199                .strip_prefix("~/")
2200                .unwrap_or(workspace_prefix);
2201            Ok(format!("{}/{session_id}", prefix.trim_end_matches('/')))
2202        }
2203    }
2204}
2205
2206/// Wrap an argv vector for execution at a provisioned session target.
2207pub fn command_on_locator(
2208    locator: &TargetLocator,
2209    session_id: &str,
2210    args: Vec<String>,
2211    purpose: impl Into<String>,
2212) -> Result<CommandSpec> {
2213    verify_locator(locator, session_id)?;
2214    if args.is_empty() {
2215        bail!("target command must not be empty");
2216    }
2217    Ok(locator_command(locator, args).purpose(purpose))
2218}
2219
2220/// Wrap a non-empty argv vector for execution at a target, without checking
2221/// that the locator belongs to a particular session. Every per-target command
2222/// is built here, so each target kind is spelled out once.
2223pub fn locator_command(locator: &TargetLocator, args: Vec<String>) -> CommandSpec {
2224    match locator {
2225        TargetLocator::LocalBare { .. } => {
2226            let mut args = args.into_iter();
2227            let program = args.next().expect("target command must not be empty");
2228            CommandSpec::new(program, args)
2229        }
2230        TargetLocator::LocalPodman { container_id, .. }
2231        | TargetLocator::LocalDocker { container_id, .. }
2232        | TargetLocator::AppleContainer { container_id, .. } => container_exec(
2233            locator.container_engine().expect("local container"),
2234            container_id,
2235            args,
2236        ),
2237        TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
2238            ssh_command_owned(ssh, args)
2239        }
2240        TargetLocator::SshPodman {
2241            ssh, container_id, ..
2242        }
2243        | TargetLocator::SshDocker {
2244            ssh, container_id, ..
2245        } => {
2246            let mut remote = vec![
2247                locator
2248                    .container_engine()
2249                    .expect("remote container")
2250                    .to_owned(),
2251                "exec".to_owned(),
2252                "-i".to_owned(),
2253                container_id.to_owned(),
2254            ];
2255            remote.extend(args);
2256            ssh_command_owned(ssh, remote)
2257        }
2258    }
2259}
2260pub fn worker_root(locator: &TargetLocator, session_id: &str) -> Result<String> {
2261    verify_locator(locator, session_id)?;
2262    Ok(match locator {
2263        TargetLocator::LocalBare { worker_root } => worker_root.clone(),
2264        TargetLocator::LocalPodman { .. }
2265        | TargetLocator::LocalDocker { .. }
2266        | TargetLocator::AppleContainer { .. }
2267        | TargetLocator::SshPodman { .. }
2268        | TargetLocator::SshDocker { .. } => format!("/var/lib/hel/workers/{session_id}"),
2269        TargetLocator::AwsEc2 { .. } => format!(".local/share/hel/workers/{session_id}"),
2270        TargetLocator::SshBare { worker_id, .. } => format!(
2271            ".local/share/hel/workers/{}",
2272            worker_id.as_deref().unwrap_or(session_id)
2273        ),
2274    })
2275}
2276mod convert;
2277pub use convert::{
2278    RecordedTarget, StoredTarget, TargetConversionError, locator_needs_connection,
2279    ssh_args_with_identity,
2280};
2281
2282mod ssh;
2283pub use ssh::*;
2284
2285pub fn container_exec(
2286    engine: &str,
2287    container_id: &str,
2288    args: impl IntoIterator<Item = impl Into<String>>,
2289) -> CommandSpec {
2290    let mut command_args = vec!["exec".to_owned(), "-i".to_owned(), container_id.to_owned()];
2291    command_args.extend(args.into_iter().map(Into::into));
2292    CommandSpec::new(engine, command_args)
2293}
2294
2295#[cfg(all(test, unix))]
2296mod executor_tests {
2297    use std::fs;
2298
2299    use super::*;
2300
2301    #[cfg(target_os = "linux")]
2302    #[test]
2303    fn successful_commands_stop_descendants_that_closed_their_pipes() {
2304        for streamed in [false, true] {
2305            let temp = tempfile::tempdir().unwrap();
2306            let pid_file = temp.path().join("descendant");
2307            let command = CommandSpec::new("sh", [
2308                "-c".to_owned(),
2309                "cat >/dev/null; head -c 131072 /dev/zero; sleep 60 </dev/null >/dev/null 2>&1 & echo $! > \"$1\"".to_owned(),
2310                "owned-descendant".to_owned(), pid_file.display().to_string(),
2311            ]);
2312            let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(5));
2313            let output = if streamed {
2314                executor
2315                    .execute_with_stdin(&command, &mut std::io::Cursor::new(vec![b'x'; 256 * 1024]))
2316            } else {
2317                executor.execute(&command)
2318            }
2319            .unwrap();
2320            assert_eq!(output.stdout.len(), 131072);
2321            let pid: i32 = fs::read_to_string(pid_file)
2322                .unwrap()
2323                .trim()
2324                .parse()
2325                .unwrap();
2326            let deadline = Instant::now() + Duration::from_secs(2);
2327            loop {
2328                let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2329                if state.as_ref().is_none_or(|state| {
2330                    state
2331                        .rsplit_once(") ")
2332                        .is_some_and(|(_, fields)| fields.starts_with('Z'))
2333                }) {
2334                    break;
2335                }
2336                if Instant::now() >= deadline {
2337                    // Do not leave the fixture behind when the regression fails.
2338                    unsafe {
2339                        libc::kill(pid, libc::SIGKILL);
2340                    }
2341                    panic!(
2342                        "successful command left descendant {pid} running (streamed={streamed})"
2343                    );
2344                }
2345                std::thread::sleep(Duration::from_millis(10));
2346            }
2347        }
2348    }
2349
2350    /// B-2 at the executor: a backgrounded descendant that holds the output
2351    /// pipes must not hold the completed command past the drain.
2352    // Hard-won: c86b2123: A target command stayed Running until a background descendant closed inherited pipes.
2353    #[cfg(target_os = "linux")]
2354    #[test]
2355    fn commands_complete_at_leader_exit_when_a_descendant_holds_the_pipes() {
2356        for streamed in [false, true] {
2357            let temp = tempfile::tempdir().unwrap();
2358            let pid_file = temp.path().join("descendant");
2359            let command = CommandSpec::new(
2360                "sh",
2361                [
2362                    "-c".to_owned(),
2363                    "sleep 300 & echo $! > \"$1\"; echo hi; exit 3".to_owned(),
2364                    "held-pipes".to_owned(),
2365                    pid_file.display().to_string(),
2366                ],
2367            );
2368            let executor = CancellableProcessExecutor::with_timeout(Duration::from_secs(30));
2369            let started = Instant::now();
2370            let output = if streamed {
2371                executor.execute_with_stdin(&command, &mut std::io::Cursor::new(b"input".to_vec()))
2372            } else {
2373                executor.execute(&command)
2374            }
2375            .unwrap();
2376            assert!(
2377                started.elapsed() < Duration::from_secs(5),
2378                "streamed={streamed} took {:?}",
2379                started.elapsed()
2380            );
2381            assert_eq!(output.status, 3);
2382            assert_eq!(output.stdout, b"hi\n");
2383            let pid: i32 = fs::read_to_string(pid_file)
2384                .unwrap()
2385                .trim()
2386                .parse()
2387                .unwrap();
2388            let deadline = Instant::now() + Duration::from_secs(2);
2389            loop {
2390                let state = fs::read_to_string(format!("/proc/{pid}/stat")).ok();
2391                if state.as_ref().is_none_or(|state| {
2392                    state
2393                        .rsplit_once(") ")
2394                        .is_some_and(|(_, fields)| fields.starts_with('Z'))
2395                }) {
2396                    break;
2397                }
2398                if Instant::now() >= deadline {
2399                    // SAFETY: cleans up this test's own fixture.
2400                    unsafe {
2401                        libc::kill(pid, libc::SIGKILL);
2402                    }
2403                    panic!("completed command left descendant {pid} (streamed={streamed})");
2404                }
2405                std::thread::sleep(Duration::from_millis(10));
2406            }
2407        }
2408    }
2409
2410    #[test]
2411    fn streamed_deadline_survives_leader_exit_and_inherited_pipes() {
2412        let command = CommandSpec::new(
2413            "sh",
2414            [
2415                "-c",
2416                "head -c 131072 /dev/zero; cat >/dev/null; (trap '' TERM; sleep 60) & exit 0",
2417            ],
2418        );
2419        let mut input = std::io::Cursor::new(vec![b'x'; 256 * 1024]);
2420        let started = Instant::now();
2421        let error = CancellableProcessExecutor::with_timeout(Duration::from_millis(300))
2422            .execute_with_stdin(&command, &mut input)
2423            .unwrap_err();
2424        assert!(error.to_string().contains("cancelled"), "{error:#}");
2425        assert!(started.elapsed() < Duration::from_secs(5));
2426    }
2427
2428    #[test]
2429    fn cleanup_timeout_replaces_an_expired_deadline_and_keeps_shared_cancellation() {
2430        let directory = tempfile::tempdir().unwrap();
2431        let marker = directory.path().join("completed");
2432        let command = CommandSpec::new(
2433            "sh",
2434            [
2435                "-c".to_owned(),
2436                "printf ran > \"$1\"".to_owned(),
2437                "cleanup-deadline".to_owned(),
2438                marker.display().to_string(),
2439            ],
2440        );
2441        let cancelled = Arc::new(AtomicBool::new(false));
2442        let executor =
2443            CancellableProcessExecutor::new(cancelled.clone()).with_deadline(Duration::ZERO);
2444
2445        let output = executor
2446            .execute_cleanup_with_timeout(&command, Duration::from_secs(5))
2447            .unwrap();
2448
2449        assert_eq!(output.status, 0);
2450        assert_eq!(fs::read_to_string(marker).unwrap(), "ran");
2451
2452        cancelled.store(true, Ordering::Release);
2453        let error = executor
2454            .execute_cleanup_with_timeout(&command, Duration::from_secs(5))
2455            .unwrap_err();
2456        assert!(
2457            error.to_string().contains("operation cancelled"),
2458            "{error:#}"
2459        );
2460    }
2461
2462    /// A stand-in for `ssh` that is refused by the server on its first call and
2463    /// connects on the next, the way a host at its `MaxStartups` ceiling
2464    /// behaves once the daemon's burst drains.
2465    fn flaky_ssh_script(directory: &Path) -> CommandSpec {
2466        let counter = directory.join("attempts");
2467        let script = format!(
2468            "count=$(cat {counter} 2>/dev/null || echo 0)\n\
2469             echo $((count + 1)) > {counter}\n\
2470             if [ \"$count\" -eq 0 ]; then\n\
2471             echo 'kex_exchange_identification: Connection closed by 10.0.0.1 port 22' >&2\n\
2472             exit 255\n\
2473             fi\n\
2474             echo connected\n",
2475            counter = counter.display()
2476        );
2477        CommandSpec::new("sh", ["-c".to_owned(), script])
2478            .ssh_destination("build@10.0.0.1")
2479            .purpose("run the flaky SSH fixture")
2480    }
2481
2482    fn attempts(directory: &Path) -> u32 {
2483        fs::read_to_string(directory.join("attempts"))
2484            .expect("the fixture records its attempts")
2485            .trim()
2486            .parse()
2487            .expect("attempt count is a number")
2488    }
2489
2490    /// An executor that models `ssh` masters and the sessions on them. The
2491    /// first session finds its master dead (it was killed after the ledger
2492    /// last checked it), which `ssh` reports the way a session guarded with
2493    /// `ProxyCommand=false` does.
2494    #[derive(Default)]
2495    struct MasterKilledOnce {
2496        running: std::cell::RefCell<BTreeSet<String>>,
2497        openers: std::cell::Cell<usize>,
2498        sessions: std::cell::RefCell<Vec<Vec<String>>>,
2499    }
2500
2501    impl CommandExecutor for MasterKilledOnce {
2502        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2503            let reply = |status: i32, stderr: &str| CommandOutput {
2504                status,
2505                stdout: Vec::new(),
2506                stderr: stderr.as_bytes().to_vec(),
2507            };
2508            if command.ssh_session.is_some() {
2509                // A session. The first one arrives just after its master died.
2510                return with_ssh_admission(command, self, &|| false, |spawned| {
2511                    self.sessions.borrow_mut().push(spawned.args.clone());
2512                    if self.sessions.borrow().len() == 1 {
2513                        self.running.borrow_mut().clear();
2514                        return Ok(reply(255, "Connection closed by UNKNOWN port 65535"));
2515                    }
2516                    Ok(reply(0, ""))
2517                });
2518            }
2519            let socket = command
2520                .args
2521                .iter()
2522                .find_map(|arg| arg.strip_prefix("ControlPath="))
2523                .expect("a master command names its socket")
2524                .to_owned();
2525            if command.args.windows(2).any(|pair| pair == ["-O", "check"]) {
2526                return Ok(reply(
2527                    if self.running.borrow().contains(&socket) {
2528                        0
2529                    } else {
2530                        255
2531                    },
2532                    "",
2533                ));
2534            }
2535            assert!(command.args.contains(&"ControlMaster=yes".to_owned()));
2536            self.openers.set(self.openers.get() + 1);
2537            self.running.borrow_mut().insert(socket);
2538            Ok(reply(0, ""))
2539        }
2540    }
2541
2542    #[test]
2543    fn a_session_whose_master_died_is_retried_on_a_reopened_master() {
2544        let _guard = ssh::SHARING_TEST_LOCK
2545            .lock()
2546            .unwrap_or_else(std::sync::PoisonError::into_inner);
2547        set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2548        let socket_dir = tempfile::tempdir_in("/tmp").expect("short socket directory");
2549        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2550            socket_dir.path().to_path_buf(),
2551        )));
2552        let ssh = SshTarget {
2553            destination: "master-killed-once-host".to_owned(),
2554            ssh_args: Vec::new(),
2555        };
2556        let executor = MasterKilledOnce::default();
2557        let output = executor.execute(&ssh_command(&ssh, ["true"]));
2558        set_ssh_connection_sharing_for_test(None);
2559        set_ssh_retry_backoff_for_test(None);
2560
2561        assert_eq!(output.expect("the retry succeeds").status, 0);
2562        let sessions = executor.sessions.borrow();
2563        assert_eq!(sessions.len(), 2);
2564        assert_eq!(
2565            executor.openers.get(),
2566            2,
2567            "the retry reopens the master instead of trusting the earlier check"
2568        );
2569        for args in sessions.iter() {
2570            assert_eq!(args[..6][5], "ProxyCommand=false", "{args:?}");
2571        }
2572    }
2573
2574    // Hard-won: e6ed54ed: sshd MaxStartups rejected concurrent session handshakes and users saw dead workers and failed closes.
2575    #[test]
2576    fn a_transport_rejected_ssh_command_is_retried_once_and_then_succeeds() {
2577        set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2578        let directory = tempfile::tempdir().expect("temp dir");
2579        let command = flaky_ssh_script(directory.path());
2580
2581        let output = ProcessExecutor
2582            .execute(&command)
2583            .expect("the retry must reach the successful attempt");
2584
2585        assert_eq!(output.status, 0);
2586        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "connected");
2587        assert_eq!(attempts(directory.path()), 2);
2588        set_ssh_retry_backoff_for_test(None);
2589    }
2590
2591    // Hard-won: e6ed54ed: sshd MaxStartups rejected remote operations during daemon startup and they were falsely reported as failed.
2592    #[test]
2593    fn the_cancellable_executor_also_retries_a_transport_rejection() {
2594        set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2595        let directory = tempfile::tempdir().expect("temp dir");
2596        let command = flaky_ssh_script(directory.path());
2597
2598        let output = CancellableProcessExecutor::new(Arc::new(AtomicBool::new(false)))
2599            .execute(&command)
2600            .expect("the retry must reach the successful attempt");
2601
2602        assert_eq!(output.status, 0);
2603        assert_eq!(attempts(directory.path()), 2);
2604        set_ssh_retry_backoff_for_test(None);
2605    }
2606}