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