Skip to main content

mj_controller/targets/
process_limit.rs

1//! Recognising a target that has run out of process slots, and saying so.
2//!
3//! A session container is created with a pids limit (see
4//! `CONTAINER_PIDS_LIMIT`), and every thread counts against it. Past the limit
5//! every `fork` and every thread spawn fails with `EAGAIN`, which reaches
6//! Mjolnir as whatever the failing program printed: "os error 11",
7//! "Resource temporarily unavailable", a shell's "Cannot fork", or Tokio's
8//! "can't spawn worker thread". A sub-agent start that fails that way has not
9//! failed for anything in its task, and its parent can fix it by closing
10//! children it no longer needs (#1161), so the parent is told exactly that.
11
12use std::time::Duration;
13
14use anyhow::{Context, Result};
15
16use super::{CommandExecutor, TargetLocator};
17
18/// How long reading the container's pid counts may take. The read runs in a
19/// container that may be full, where it can hang or fail; it only adds detail.
20pub const PIDS_USAGE_READ_TIMEOUT: Duration = Duration::from_secs(5);
21
22/// What the failing programs print when `fork` or a thread spawn returns
23/// `EAGAIN`. Matched without regard to case. Rust prints the errno in
24/// parentheses, and the closing one keeps `(os error 111)`, a refused
25/// connection, from matching.
26const PROCESS_EXHAUSTION_MARKERS: [&str; 5] = [
27    "(os error 11)",
28    "resource temporarily unavailable",
29    "cannot fork",
30    "can't fork",
31    "can't spawn worker thread",
32];
33
34/// Whether an error's chain shows that its target could not create another
35/// process or thread.
36#[must_use]
37pub fn shows_process_exhaustion(error: &anyhow::Error) -> bool {
38    error.chain().any(|cause| {
39        let text = cause.to_string().to_ascii_lowercase();
40        PROCESS_EXHAUSTION_MARKERS
41            .iter()
42            .any(|marker| text.contains(marker))
43    })
44}
45
46/// The pid counts of a container's cgroup: `pids.current`, and `pids.max`,
47/// which is `None` when the cgroup has no limit (the file says `max`).
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct PidsUsage {
50    pub current: u64,
51    pub max: Option<u64>,
52}
53
54/// Prints `pids.current` and `pids.max` of the cgroup the command runs in,
55/// under cgroup v2 or, failing that, v1.
56const PIDS_USAGE_SCRIPT: &str = "cat /sys/fs/cgroup/pids.current /sys/fs/cgroup/pids.max 2>/dev/null \
57     || cat /sys/fs/cgroup/pids/pids.current /sys/fs/cgroup/pids/pids.max";
58
59/// Whether a target runs its sessions in a container, which is what has a
60/// pids limit of its own.
61#[must_use]
62pub fn is_container(locator: &TargetLocator) -> bool {
63    matches!(
64        locator,
65        TargetLocator::LocalPodman { .. }
66            | TargetLocator::LocalDocker { .. }
67            | TargetLocator::AppleContainer { .. }
68            | TargetLocator::SshPodman { .. }
69            | TargetLocator::SshDocker { .. }
70    )
71}
72
73/// Read the pid counts of the container `locator` names, from inside it.
74pub fn read_pids_usage(
75    executor: &impl CommandExecutor,
76    locator: &TargetLocator,
77    session_id: &str,
78) -> Result<PidsUsage> {
79    let command = super::command_on_locator(
80        locator,
81        session_id,
82        vec!["sh".into(), "-c".into(), PIDS_USAGE_SCRIPT.into()],
83        "read the container's pid counts",
84    )?;
85    let output = executor.execute(&command)?;
86    anyhow::ensure!(
87        output.status == 0,
88        "reading the pid counts failed: {}",
89        String::from_utf8_lossy(&output.stderr).trim()
90    );
91    parse_pids_usage(&String::from_utf8_lossy(&output.stdout))
92}
93
94fn parse_pids_usage(text: &str) -> Result<PidsUsage> {
95    let mut lines = text.split_whitespace();
96    let current = lines
97        .next()
98        .context("no pids.current value")?
99        .parse()
100        .context("pids.current is not a number")?;
101    let max = match lines.next().context("no pids.max value")? {
102        "max" => None,
103        value => Some(value.parse().context("pids.max is not a number")?),
104    };
105    Ok(PidsUsage { current, max })
106}
107
108/// The message a parent model reads when a sub-agent could not be started
109/// because its target ran out of process slots. `usage` is the result of
110/// [`read_pids_usage`] for a container, or `None` for a target that is not
111/// one. The original error follows, so nothing it said is lost.
112#[must_use]
113pub fn process_exhaustion_message(
114    usage: Option<&Result<PidsUsage>>,
115    error: &anyhow::Error,
116) -> String {
117    let (place, counts) = match usage {
118        None => ("the target machine", String::new()),
119        Some(Ok(PidsUsage { current, max })) => (
120            "the parent's container",
121            match max {
122                Some(max) => format!(" (pids.current {current} of pids.max {max})"),
123                None => format!(" (pids.current {current}, with no pids.max limit)"),
124            },
125        ),
126        Some(Err(_)) => (
127            "the parent's container",
128            " (its pids.current and pids.max could not be read, which is what happens when \
129             it is completely full)"
130                .to_owned(),
131        ),
132    };
133    format!(
134        "{place} ran out of process slots{counts}, so the sub-agent's harness could not \
135         start. Every live sub-agent holds hundreds of threads there. Close sub-agents you \
136         no longer need, then try again. The original error was: {error:#}"
137    )
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    // Hard-won: #1161: A full container PID limit must be recognized when the parent harness runs out of slots.
145    #[test]
146    fn every_way_a_full_target_reports_itself_is_recognised() {
147        for text in [
148            "failed to spawn thread: Resource temporarily unavailable (os error 11)",
149            "sh: 1: Cannot fork",
150            "OS can't spawn worker thread: Resource temporarily unavailable (os error 11)",
151        ] {
152            let error = anyhow::anyhow!("{text}").context("start the sub-agent");
153            assert!(shows_process_exhaustion(&error), "{text}");
154        }
155        for text in [
156            "Connection refused (os error 111)",
157            "Connection timed out (os error 110)",
158            "permission denied (os error 13)",
159        ] {
160            let error = anyhow::anyhow!("{text}");
161            assert!(!shows_process_exhaustion(&error), "{text}");
162        }
163    }
164
165    // Hard-won: #1161: PID exhaustion errors must explain the current and maximum process counts.
166    #[test]
167    fn the_message_names_the_counts_or_says_they_could_not_be_read() {
168        let error = anyhow::anyhow!("sh: 1: Cannot fork");
169        let read = process_exhaustion_message(
170            Some(&Ok(PidsUsage {
171                current: 8190,
172                max: Some(8192),
173            })),
174            &error,
175        );
176        assert!(read.starts_with("the parent's container ran out of process slots"));
177        assert!(
178            read.contains("pids.current 8190 of pids.max 8192"),
179            "{read}"
180        );
181        assert!(
182            read.contains("Close sub-agents you no longer need"),
183            "{read}"
184        );
185        assert!(
186            read.ends_with("The original error was: sh: 1: Cannot fork"),
187            "{read}"
188        );
189
190        let unread = process_exhaustion_message(Some(&Err(anyhow::anyhow!("Cannot fork"))), &error);
191        assert!(
192            unread.contains("its pids.current and pids.max could not be read"),
193            "{unread}"
194        );
195        assert!(
196            unread.contains("Close sub-agents you no longer need"),
197            "{unread}"
198        );
199    }
200
201    // Hard-won: #1161: PID diagnostics must parse cgroup current/max values and the unlimited ceiling.
202    #[test]
203    fn pid_counts_parse_with_and_without_a_limit() {
204        assert_eq!(
205            parse_pids_usage("1684\n8192\n").unwrap(),
206            PidsUsage {
207                current: 1684,
208                max: Some(8192)
209            }
210        );
211        assert_eq!(
212            parse_pids_usage("12\nmax\n").unwrap(),
213            PidsUsage {
214                current: 12,
215                max: None
216            }
217        );
218        assert!(parse_pids_usage("").is_err());
219    }
220}