mj_controller/targets/
process_limit.rs1use std::time::Duration;
13
14use anyhow::{Context, Result};
15
16use super::{CommandExecutor, TargetLocator};
17
18pub const PIDS_USAGE_READ_TIMEOUT: Duration = Duration::from_secs(5);
21
22const 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct PidsUsage {
50 pub current: u64,
51 pub max: Option<u64>,
52}
53
54const 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#[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
73pub 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#[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 #[test]
145 fn every_way_a_full_target_reports_itself_is_recognised() {
146 for text in [
147 "failed to spawn thread: Resource temporarily unavailable (os error 11)",
148 "sh: 1: Cannot fork",
149 "OS can't spawn worker thread: Resource temporarily unavailable (os error 11)",
150 ] {
151 let error = anyhow::anyhow!("{text}").context("start the sub-agent");
152 assert!(shows_process_exhaustion(&error), "{text}");
153 }
154 for text in [
155 "Connection refused (os error 111)",
156 "Connection timed out (os error 110)",
157 "permission denied (os error 13)",
158 ] {
159 let error = anyhow::anyhow!("{text}");
160 assert!(!shows_process_exhaustion(&error), "{text}");
161 }
162 }
163
164 #[test]
165 fn the_message_names_the_counts_or_says_they_could_not_be_read() {
166 let error = anyhow::anyhow!("sh: 1: Cannot fork");
167 let read = process_exhaustion_message(
168 Some(&Ok(PidsUsage {
169 current: 8190,
170 max: Some(8192),
171 })),
172 &error,
173 );
174 assert!(read.starts_with("the parent's container ran out of process slots"));
175 assert!(
176 read.contains("pids.current 8190 of pids.max 8192"),
177 "{read}"
178 );
179 assert!(
180 read.contains("Close sub-agents you no longer need"),
181 "{read}"
182 );
183 assert!(
184 read.ends_with("The original error was: sh: 1: Cannot fork"),
185 "{read}"
186 );
187
188 let unread = process_exhaustion_message(Some(&Err(anyhow::anyhow!("Cannot fork"))), &error);
189 assert!(
190 unread.contains("its pids.current and pids.max could not be read"),
191 "{unread}"
192 );
193 assert!(
194 unread.contains("Close sub-agents you no longer need"),
195 "{unread}"
196 );
197 }
198
199 #[test]
200 fn pid_counts_parse_with_and_without_a_limit() {
201 assert_eq!(
202 parse_pids_usage("1684\n8192\n").unwrap(),
203 PidsUsage {
204 current: 1684,
205 max: Some(8192)
206 }
207 );
208 assert_eq!(
209 parse_pids_usage("12\nmax\n").unwrap(),
210 PidsUsage {
211 current: 12,
212 max: None
213 }
214 );
215 assert!(parse_pids_usage("").is_err());
216 }
217}