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 let _ = fs::remove_dir(&cgroup_full);
115 }
116 }
117 }
118
119 if memory_method != "cgroup" && max_memory_bytes.is_some() {
121 unsafe {
122 cmd.pre_exec(move || {
123 if let Some(mem_limit) = max_memory_bytes {
124 if let Err(e) = apply_memory_limit_setrlimit(mem_limit) {
125 tracing::warn!("Failed to apply memory limit via setrlimit: {}", e);
126 } else {
127 tracing::debug!(
128 "Applied memory limit via setrlimit: {} MB",
129 mem_limit / 1024 / 1024
130 );
131 }
132 }
133 Ok(())
134 });
135 }
136 memory_method = "setrlimit";
137 }
138
139 tracing::info!(
140 "Using resource limits: memory={} ({}), CPU={} ({})",
141 self.max_memory_percent
142 .map(|p| format!("{}%", p))
143 .unwrap_or("unlimited".to_string()),
144 memory_method,
145 self.max_cpu_percent
146 .map(|c| format!("{}%", c))
147 .unwrap_or("unlimited".to_string()),
148 if cpu_method == "none" {
149 "unavailable"
150 } else {
151 cpu_method
152 }
153 );
154
155 Ok(())
156 }
157}
158
159#[cfg(target_os = "linux")]
161fn find_user_cgroup() -> Option<PathBuf> {
162 let cgroup_root = PathBuf::from("/sys/fs/cgroup");
163 if !cgroup_root.exists() {
164 tracing::debug!("cgroups v2 not available at /sys/fs/cgroup");
165 return None;
166 }
167
168 let uid = get_uid();
169
170 let locations = vec![
172 cgroup_root.join(format!(
173 "user.slice/user-{}.slice/user@{}.service/app.slice",
174 uid, uid
175 )),
176 cgroup_root.join(format!(
177 "user.slice/user-{}.slice/user@{}.service",
178 uid, uid
179 )),
180 cgroup_root.join(format!("user.slice/user-{}.slice", uid)),
181 cgroup_root.join(format!("user-{}", uid)),
182 ];
183
184 for parent in locations {
185 if !parent.exists() {
186 continue;
187 }
188
189 let test_file = parent.join("cgroup.procs");
191 if is_writable(&test_file) {
192 tracing::debug!("Found writable user cgroup: {:?}", parent);
193 return Some(parent);
194 }
195 }
196
197 tracing::debug!("No writable user-delegated cgroup found");
198 None
199}
200
201#[cfg(target_os = "linux")]
203fn set_cgroup_memory(cgroup_path: &Path, bytes: u64) -> io::Result<()> {
204 let memory_max_file = cgroup_path.join("memory.max");
205 fs::write(&memory_max_file, format!("{}", bytes))?;
206 Ok(())
207}
208
209#[cfg(target_os = "linux")]
211fn set_cgroup_cpu(cgroup_path: &Path, percent: u32) -> io::Result<()> {
212 let period_us = 100_000;
215 let max_us = (period_us * percent as u64) / 100;
216 let cpu_max_file = cgroup_path.join("cpu.max");
217 fs::write(&cpu_max_file, format!("{} {}", max_us, period_us))?;
218 Ok(())
219}
220
221#[cfg(target_os = "linux")]
223fn is_writable(path: &Path) -> bool {
224 use std::os::unix::fs::PermissionsExt;
225
226 if let Ok(metadata) = fs::metadata(path) {
227 let permissions = metadata.permissions();
228 permissions.mode() & 0o200 != 0
230 } else {
231 false
232 }
233}
234
235#[cfg(target_os = "linux")]
237fn move_to_cgroup(cgroup_path: &Path) -> io::Result<()> {
238 let procs_file = cgroup_path.join("cgroup.procs");
239 let pid = std::process::id();
240 fs::write(&procs_file, format!("{}", pid))?;
241 Ok(())
242}
243
244#[cfg(target_os = "linux")]
246fn get_uid() -> u32 {
247 unsafe { libc::getuid() }
248}
249
250pub struct SystemResources;
252
253impl SystemResources {
254 pub fn total_memory_mb() -> io::Result<u64> {
256 #[cfg(target_os = "linux")]
257 {
258 Self::linux_total_memory_mb()
259 }
260
261 #[cfg(not(target_os = "linux"))]
262 {
263 Err(io::Error::new(
265 io::ErrorKind::Unsupported,
266 "Memory detection not implemented for this platform",
267 ))
268 }
269 }
270
271 #[cfg(target_os = "linux")]
272 fn linux_total_memory_mb() -> io::Result<u64> {
273 let meminfo = std::fs::read_to_string("/proc/meminfo")?;
275
276 for line in meminfo.lines() {
277 if line.starts_with("MemTotal:") {
278 let parts: Vec<&str> = line.split_whitespace().collect();
280 if parts.len() >= 2 {
281 if let Ok(kb) = parts[1].parse::<u64>() {
282 return Ok(kb / 1024); }
284 }
285 }
286 }
287
288 Err(io::Error::new(
289 io::ErrorKind::InvalidData,
290 "Could not parse MemTotal from /proc/meminfo",
291 ))
292 }
293
294 pub fn cpu_count() -> io::Result<usize> {
296 #[cfg(target_os = "linux")]
297 {
298 Ok(num_cpus())
299 }
300
301 #[cfg(not(target_os = "linux"))]
302 {
303 Err(io::Error::new(
305 io::ErrorKind::Unsupported,
306 "CPU detection not implemented for this platform",
307 ))
308 }
309 }
310}
311
312#[cfg(target_os = "linux")]
314fn apply_memory_limit_setrlimit(bytes: u64) -> io::Result<()> {
315 use nix::sys::resource::{setrlimit, Resource};
316
317 setrlimit(Resource::RLIMIT_AS, bytes, bytes)
319 .map_err(|e| io::Error::other(format!("setrlimit AS failed: {}", e)))
320}
321
322#[cfg(target_os = "linux")]
324fn num_cpus() -> usize {
325 std::thread::available_parallelism()
326 .map(|n| n.get())
327 .unwrap_or(1)
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn test_process_limits_default() {
336 let limits = ProcessLimits::default();
337
338 #[cfg(target_os = "linux")]
339 {
340 assert!(limits.enabled);
341 assert_eq!(limits.max_memory_percent, Some(50));
342 assert_eq!(limits.max_cpu_percent, Some(90));
343 }
344
345 #[cfg(not(target_os = "linux"))]
346 {
347 assert!(!limits.enabled);
348 }
349 }
350
351 #[test]
352 fn test_process_limits_unlimited() {
353 let limits = ProcessLimits::unlimited();
354 assert!(!limits.enabled);
355 assert_eq!(limits.max_memory_percent, None);
356 assert_eq!(limits.max_cpu_percent, None);
357 }
358
359 #[test]
360 fn test_process_limits_serialization() {
361 let limits = ProcessLimits {
362 max_memory_percent: Some(50),
363 max_cpu_percent: Some(80),
364 enabled: true,
365 };
366
367 let json = serde_json::to_string(&limits).unwrap();
368 let deserialized: ProcessLimits = serde_json::from_str(&json).unwrap();
369
370 assert_eq!(limits, deserialized);
371 }
372
373 #[test]
374 #[cfg(target_os = "linux")]
375 fn test_system_resources_memory() {
376 let mem_mb = SystemResources::total_memory_mb();
377 assert!(mem_mb.is_ok());
378
379 if let Ok(mem) = mem_mb {
380 assert!(mem > 0);
381 println!("Total system memory: {} MB", mem);
382 }
383 }
384
385 #[test]
386 #[cfg(target_os = "linux")]
387 fn test_system_resources_cpu() {
388 let cpu_count = SystemResources::cpu_count();
389 assert!(cpu_count.is_ok());
390
391 if let Ok(count) = cpu_count {
392 assert!(count > 0);
393 println!("Total CPU cores: {}", count);
394 }
395 }
396
397 #[test]
398 fn test_process_limits_apply_to_command_disabled() {
399 let limits = ProcessLimits::unlimited();
400 let mut cmd = tokio::process::Command::new("echo");
401
402 let result = limits.apply_to_command(&mut cmd);
404 assert!(result.is_ok());
405 }
406
407 #[test]
408 #[cfg(target_os = "linux")]
409 fn test_memory_limit_bytes_calculation() {
410 let limits = ProcessLimits {
411 max_memory_percent: Some(50),
412 max_cpu_percent: Some(90),
413 enabled: true,
414 };
415
416 let memory_bytes = limits.memory_limit_bytes();
417
418 assert!(memory_bytes.is_some());
420
421 if let Some(bytes) = memory_bytes {
422 let total_memory = SystemResources::total_memory_mb().unwrap();
424 let expected_bytes = (total_memory / 2) * 1024 * 1024;
425
426 assert!((bytes as i64 - expected_bytes as i64).abs() < 10 * 1024 * 1024);
428 }
429 }
430
431 #[test]
432 fn test_process_limits_json_with_null_memory() {
433 let json = r#"{
435 "max_memory_percent": null,
436 "max_cpu_percent": 90,
437 "enabled": true
438 }"#;
439
440 let limits: ProcessLimits = serde_json::from_str(json).unwrap();
441 assert_eq!(limits.max_memory_percent, None);
442 assert_eq!(limits.max_cpu_percent, Some(90));
443 assert!(limits.enabled);
444 }
445
446 #[tokio::test]
447 #[cfg(target_os = "linux")]
448 async fn test_spawn_process_with_limits() {
449 let limits = ProcessLimits {
451 max_memory_percent: Some(10), max_cpu_percent: Some(50),
453 enabled: true,
454 };
455
456 let mut cmd = tokio::process::Command::new("echo");
457 cmd.arg("test");
458
459 limits.apply_to_command(&mut cmd).unwrap();
461
462 let output = cmd.output().await;
464
465 assert!(output.is_ok());
467 let output = output.unwrap();
468 assert!(output.status.success());
469 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "test");
470 }
471
472 #[test]
473 #[cfg(target_os = "linux")]
474 fn test_user_cgroup_detection() {
475 let cgroup = find_user_cgroup();
477 match cgroup {
478 Some(path) => {
479 println!("✓ Found writable user cgroup at: {:?}", path);
480 }
481 None => {
482 println!("✗ No writable user cgroup found");
483 }
484 }
485 }
486
487 #[test]
488 #[cfg(target_os = "linux")]
489 fn test_memory_limit_independent() {
490 let _limits = ProcessLimits {
492 max_memory_percent: Some(10),
493 max_cpu_percent: None, enabled: true,
495 };
496
497 if let Some(cgroup) = find_user_cgroup() {
498 let test_cgroup = cgroup.join("test-memory-only");
499 if fs::create_dir(&test_cgroup).is_ok() {
500 let result = set_cgroup_memory(&test_cgroup, 100 * 1024 * 1024);
502
503 if result.is_ok() {
504 println!("✓ Memory limit works independently");
505 } else {
506 println!("✗ Memory limit failed: {:?}", result.err());
507 }
508
509 let _ = fs::remove_dir(&test_cgroup);
511 }
512 } else {
513 println!("⊘ No user cgroup available for testing");
514 }
515 }
516}