fresh/services/
process_limits.rs1pub use crate::types::ProcessLimits;
8
9use std::io;
10
11#[cfg(target_os = "linux")]
12use std::fs;
13#[cfg(target_os = "linux")]
14use std::path::{Path, PathBuf};
15
16impl ProcessLimits {
17 pub fn memory_limit_bytes(&self) -> Option<u64> {
19 self.max_memory_percent.and_then(|percent| {
20 SystemResources::total_memory_mb()
21 .ok()
22 .map(|total_mb| (total_mb * percent as u64 / 100) * 1024 * 1024)
23 })
24 }
25
26 pub fn apply_to_command(&self, _cmd: &mut tokio::process::Command) -> io::Result<()> {
31 if !self.enabled {
32 return Ok(());
33 }
34
35 #[cfg(target_os = "linux")]
36 {
37 self.apply_linux_limits(_cmd)
38 }
39
40 #[cfg(not(target_os = "linux"))]
41 {
42 tracing::info!("Process resource limits are not yet implemented for this platform");
45 Ok(())
46 }
47 }
48
49 #[cfg(target_os = "linux")]
50 fn apply_linux_limits(&self, cmd: &mut tokio::process::Command) -> io::Result<()> {
51 let max_memory_bytes = self.memory_limit_bytes();
52 let _max_cpu_percent = self.max_cpu_percent;
53
54 let cgroup_path = find_user_cgroup();
56
57 let mut memory_method = "none";
59 let mut cpu_method = "none";
60
61 if let Some(ref cgroup_base) = cgroup_path {
63 let pid = std::process::id();
64 let cgroup_name = format!("editor-lsp-{}", pid);
65 let cgroup_full = cgroup_base.join(&cgroup_name);
66
67 if fs::create_dir(&cgroup_full).is_ok() {
69 if let Some(memory_bytes) = max_memory_bytes {
71 if set_cgroup_memory(&cgroup_full, memory_bytes).is_ok() {
72 memory_method = "cgroup";
73 tracing::debug!(
74 "Set memory limit via cgroup: {} MB ({}% of system)",
75 memory_bytes / 1024 / 1024,
76 self.max_memory_percent.unwrap_or(0)
77 );
78 }
79 }
80
81 if let Some(cpu_pct) = self.max_cpu_percent {
83 if set_cgroup_cpu(&cgroup_full, cpu_pct).is_ok() {
84 cpu_method = "cgroup";
85 tracing::debug!("Set CPU limit via cgroup: {}%", cpu_pct);
86 }
87 }
88
89 if memory_method == "cgroup" || cpu_method == "cgroup" {
91 let cgroup_to_use = cgroup_full.clone();
92
93 unsafe {
94 cmd.pre_exec(move || {
95 if let Err(e) = move_to_cgroup(&cgroup_to_use) {
97 tracing::warn!("Failed to move process to cgroup: {}", e);
98 }
99 Ok(())
100 });
101 }
102
103 tracing::info!(
104 "Using resource limits: memory={} ({}), CPU={} ({})",
105 self.max_memory_percent
106 .map(|p| format!("{}%", p))
107 .unwrap_or("unlimited".to_string()),
108 memory_method,
109 self.max_cpu_percent
110 .map(|c| format!("{}%", c))
111 .unwrap_or("unlimited".to_string()),
112 cpu_method
113 );
114 return Ok(());
115 } else {
116 #[allow(clippy::let_underscore_must_use)]
118 let _ = fs::remove_dir(&cgroup_full);
119 }
120 }
121 }
122
123 if memory_method != "cgroup" && max_memory_bytes.is_some() {
125 unsafe {
126 cmd.pre_exec(move || {
127 if let Some(mem_limit) = max_memory_bytes {
128 if let Err(e) = apply_memory_limit_setrlimit(mem_limit) {
129 tracing::warn!("Failed to apply memory limit via setrlimit: {}", e);
130 } else {
131 tracing::debug!(
132 "Applied memory limit via setrlimit: {} MB",
133 mem_limit / 1024 / 1024
134 );
135 }
136 }
137 Ok(())
138 });
139 }
140 memory_method = "setrlimit";
141 }
142
143 tracing::info!(
144 "Using resource limits: memory={} ({}), CPU={} ({})",
145 self.max_memory_percent
146 .map(|p| format!("{}%", p))
147 .unwrap_or("unlimited".to_string()),
148 memory_method,
149 self.max_cpu_percent
150 .map(|c| format!("{}%", c))
151 .unwrap_or("unlimited".to_string()),
152 if cpu_method == "none" {
153 "unavailable"
154 } else {
155 cpu_method
156 }
157 );
158
159 Ok(())
160 }
161}
162
163#[cfg(target_os = "linux")]
165fn find_user_cgroup() -> Option<PathBuf> {
166 let cgroup_root = PathBuf::from("/sys/fs/cgroup");
167 if !cgroup_root.exists() {
168 tracing::debug!("cgroups v2 not available at /sys/fs/cgroup");
169 return None;
170 }
171
172 let uid = get_uid();
173
174 let locations = vec![
176 cgroup_root.join(format!(
177 "user.slice/user-{}.slice/user@{}.service/app.slice",
178 uid, uid
179 )),
180 cgroup_root.join(format!(
181 "user.slice/user-{}.slice/user@{}.service",
182 uid, uid
183 )),
184 cgroup_root.join(format!("user.slice/user-{}.slice", uid)),
185 cgroup_root.join(format!("user-{}", uid)),
186 ];
187
188 for parent in locations {
189 if !parent.exists() {
190 continue;
191 }
192
193 let test_file = parent.join("cgroup.procs");
195 if is_writable(&test_file) {
196 tracing::debug!("Found writable user cgroup: {:?}", parent);
197 return Some(parent);
198 }
199 }
200
201 tracing::debug!("No writable user-delegated cgroup found");
202 None
203}
204
205#[cfg(target_os = "linux")]
207fn set_cgroup_memory(cgroup_path: &Path, bytes: u64) -> io::Result<()> {
208 let memory_max_file = cgroup_path.join("memory.max");
209 fs::write(&memory_max_file, format!("{}", bytes))?;
210 Ok(())
211}
212
213#[cfg(target_os = "linux")]
215fn set_cgroup_cpu(cgroup_path: &Path, percent: u32) -> io::Result<()> {
216 let period_us = 100_000;
219 let max_us = (period_us * percent as u64) / 100;
220 let cpu_max_file = cgroup_path.join("cpu.max");
221 fs::write(&cpu_max_file, format!("{} {}", max_us, period_us))?;
222 Ok(())
223}
224
225#[cfg(target_os = "linux")]
227fn is_writable(path: &Path) -> bool {
228 use std::os::unix::fs::PermissionsExt;
229
230 if let Ok(metadata) = fs::metadata(path) {
231 let permissions = metadata.permissions();
232 permissions.mode() & 0o200 != 0
234 } else {
235 false
236 }
237}
238
239#[cfg(target_os = "linux")]
241fn move_to_cgroup(cgroup_path: &Path) -> io::Result<()> {
242 let procs_file = cgroup_path.join("cgroup.procs");
243 let pid = std::process::id();
244 fs::write(&procs_file, format!("{}", pid))?;
245 Ok(())
246}
247
248#[cfg(target_os = "linux")]
250fn get_uid() -> u32 {
251 unsafe { libc::getuid() }
252}
253
254pub struct SystemResources;
256
257impl SystemResources {
258 pub fn total_memory_mb() -> io::Result<u64> {
260 #[cfg(target_os = "linux")]
261 {
262 Self::linux_total_memory_mb()
263 }
264
265 #[cfg(not(target_os = "linux"))]
266 {
267 Err(io::Error::new(
269 io::ErrorKind::Unsupported,
270 "Memory detection not implemented for this platform",
271 ))
272 }
273 }
274
275 #[cfg(target_os = "linux")]
276 fn linux_total_memory_mb() -> io::Result<u64> {
277 let meminfo = std::fs::read_to_string("/proc/meminfo")?;
279
280 for line in meminfo.lines() {
281 if line.starts_with("MemTotal:") {
282 let parts: Vec<&str> = line.split_whitespace().collect();
284 if parts.len() >= 2 {
285 if let Ok(kb) = parts[1].parse::<u64>() {
286 return Ok(kb / 1024); }
288 }
289 }
290 }
291
292 Err(io::Error::new(
293 io::ErrorKind::InvalidData,
294 "Could not parse MemTotal from /proc/meminfo",
295 ))
296 }
297
298 pub fn cpu_count() -> io::Result<usize> {
300 #[cfg(target_os = "linux")]
301 {
302 Ok(num_cpus())
303 }
304
305 #[cfg(not(target_os = "linux"))]
306 {
307 Err(io::Error::new(
309 io::ErrorKind::Unsupported,
310 "CPU detection not implemented for this platform",
311 ))
312 }
313 }
314}
315
316#[cfg(target_os = "linux")]
318fn apply_memory_limit_setrlimit(bytes: u64) -> io::Result<()> {
319 use nix::sys::resource::{setrlimit, Resource};
320
321 let limit = bytes as nix::libc::rlim_t;
325 setrlimit(Resource::RLIMIT_AS, limit, limit)
326 .map_err(|e| io::Error::other(format!("setrlimit AS failed: {}", e)))
327}
328
329#[cfg(target_os = "linux")]
331fn num_cpus() -> usize {
332 std::thread::available_parallelism()
333 .map(|n| n.get())
334 .unwrap_or(1)
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn test_process_limits_default() {
343 let limits = ProcessLimits::default();
344
345 #[cfg(target_os = "linux")]
346 {
347 assert!(limits.enabled);
348 assert_eq!(limits.max_memory_percent, Some(50));
349 assert_eq!(limits.max_cpu_percent, Some(90));
350 }
351
352 #[cfg(not(target_os = "linux"))]
353 {
354 assert!(!limits.enabled);
355 }
356 }
357
358 #[test]
359 fn test_process_limits_unlimited() {
360 let limits = ProcessLimits::unlimited();
361 assert!(!limits.enabled);
362 assert_eq!(limits.max_memory_percent, None);
363 assert_eq!(limits.max_cpu_percent, None);
364 }
365
366 #[test]
367 fn test_process_limits_serialization() {
368 let limits = ProcessLimits {
369 max_memory_percent: Some(50),
370 max_cpu_percent: Some(80),
371 enabled: true,
372 };
373
374 let json = serde_json::to_string(&limits).unwrap();
375 let deserialized: ProcessLimits = serde_json::from_str(&json).unwrap();
376
377 assert_eq!(limits, deserialized);
378 }
379
380 #[test]
381 #[cfg(target_os = "linux")]
382 fn test_system_resources_memory() {
383 let mem_mb = SystemResources::total_memory_mb();
384 assert!(mem_mb.is_ok());
385
386 if let Ok(mem) = mem_mb {
387 assert!(mem > 0);
388 println!("Total system memory: {} MB", mem);
389 }
390 }
391
392 #[test]
393 #[cfg(target_os = "linux")]
394 fn test_system_resources_cpu() {
395 let cpu_count = SystemResources::cpu_count();
396 assert!(cpu_count.is_ok());
397
398 if let Ok(count) = cpu_count {
399 assert!(count > 0);
400 println!("Total CPU cores: {}", count);
401 }
402 }
403
404 #[test]
405 fn test_process_limits_apply_to_command_disabled() {
406 let limits = ProcessLimits::unlimited();
407 let mut cmd = tokio::process::Command::new("echo");
408
409 let result = limits.apply_to_command(&mut cmd);
411 assert!(result.is_ok());
412 }
413
414 #[test]
415 #[cfg(target_os = "linux")]
416 fn test_memory_limit_bytes_calculation() {
417 let limits = ProcessLimits {
418 max_memory_percent: Some(50),
419 max_cpu_percent: Some(90),
420 enabled: true,
421 };
422
423 let memory_bytes = limits.memory_limit_bytes();
424
425 assert!(memory_bytes.is_some());
427
428 if let Some(bytes) = memory_bytes {
429 let total_memory = SystemResources::total_memory_mb().unwrap();
431 let expected_bytes = (total_memory / 2) * 1024 * 1024;
432
433 assert!((bytes as i64 - expected_bytes as i64).abs() < 10 * 1024 * 1024);
435 }
436 }
437
438 #[test]
439 fn test_process_limits_json_with_null_memory() {
440 let json = r#"{
442 "max_memory_percent": null,
443 "max_cpu_percent": 90,
444 "enabled": true
445 }"#;
446
447 let limits: ProcessLimits = serde_json::from_str(json).unwrap();
448 assert_eq!(limits.max_memory_percent, None);
449 assert_eq!(limits.max_cpu_percent, Some(90));
450 assert!(limits.enabled);
451 }
452
453 #[tokio::test]
454 #[cfg(target_os = "linux")]
455 async fn test_spawn_process_with_limits() {
456 let limits = ProcessLimits {
458 max_memory_percent: Some(10), max_cpu_percent: Some(50),
460 enabled: true,
461 };
462
463 let mut cmd = tokio::process::Command::new("echo");
464 cmd.arg("test");
465
466 limits.apply_to_command(&mut cmd).unwrap();
468
469 let output = cmd.output().await;
471
472 assert!(output.is_ok());
474 let output = output.unwrap();
475 assert!(output.status.success());
476 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "test");
477 }
478
479 #[test]
480 #[cfg(target_os = "linux")]
481 fn test_user_cgroup_detection() {
482 let cgroup = find_user_cgroup();
484 match cgroup {
485 Some(path) => {
486 println!("✓ Found writable user cgroup at: {:?}", path);
487 }
488 None => {
489 println!("✗ No writable user cgroup found");
490 }
491 }
492 }
493
494 #[test]
495 #[cfg(target_os = "linux")]
496 fn test_memory_limit_independent() {
497 let _limits = ProcessLimits {
499 max_memory_percent: Some(10),
500 max_cpu_percent: None, enabled: true,
502 };
503
504 if let Some(cgroup) = find_user_cgroup() {
505 let test_cgroup = cgroup.join("test-memory-only");
506 if fs::create_dir(&test_cgroup).is_ok() {
507 let result = set_cgroup_memory(&test_cgroup, 100 * 1024 * 1024);
509
510 if result.is_ok() {
511 println!("✓ Memory limit works independently");
512 } else {
513 println!("✗ Memory limit failed: {:?}", result.err());
514 }
515
516 drop(fs::remove_dir(&test_cgroup));
518 }
519 } else {
520 println!("⊘ No user cgroup available for testing");
521 }
522 }
523}