1pub 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
16#[derive(Debug, Default)]
27pub struct PostSpawnAction {
28 #[cfg(target_os = "linux")]
31 cgroup_dir: Option<PathBuf>,
32}
33
34impl PostSpawnAction {
35 pub fn apply_to_child(&self, _child_pid: u32) {
43 #[cfg(target_os = "linux")]
44 if let Some(ref cgroup_dir) = self.cgroup_dir {
45 let procs_file = cgroup_dir.join("cgroup.procs");
46 if let Err(e) = fs::write(&procs_file, format!("{}", _child_pid)) {
47 tracing::warn!(
48 "Failed to move child {} into cgroup {:?}: {}",
49 _child_pid,
50 cgroup_dir,
51 e
52 );
53 }
54 }
55 }
56}
57
58impl ProcessLimits {
59 pub fn memory_limit_bytes(&self) -> Option<u64> {
61 self.max_memory_percent.and_then(|percent| {
62 SystemResources::total_memory_mb()
63 .ok()
64 .map(|total_mb| (total_mb * percent as u64 / 100) * 1024 * 1024)
65 })
66 }
67
68 pub fn apply_to_command(
78 &self,
79 _cmd: &mut tokio::process::Command,
80 ) -> io::Result<PostSpawnAction> {
81 if !self.enabled {
82 return Ok(PostSpawnAction::default());
83 }
84
85 #[cfg(target_os = "linux")]
86 {
87 self.apply_linux_limits(_cmd)
88 }
89
90 #[cfg(not(target_os = "linux"))]
91 {
92 tracing::info!("Process resource limits are not yet implemented for this platform");
95 Ok(PostSpawnAction::default())
96 }
97 }
98
99 #[cfg(target_os = "linux")]
100 fn apply_linux_limits(&self, cmd: &mut tokio::process::Command) -> io::Result<PostSpawnAction> {
101 let max_memory_bytes = self.memory_limit_bytes();
102 let _max_cpu_percent = self.max_cpu_percent;
103
104 let cgroup_path = find_user_cgroup();
106
107 let mut memory_method = "none";
109 let mut cpu_method = "none";
110 let mut action = PostSpawnAction::default();
111
112 if let Some(ref cgroup_base) = cgroup_path {
116 let pid = std::process::id();
117 let cgroup_name = format!("editor-lsp-{}", pid);
118 let cgroup_full = cgroup_base.join(&cgroup_name);
119
120 let cgroup_usable = match fs::create_dir(&cgroup_full) {
123 Ok(()) => {
124 if let Some(memory_bytes) = max_memory_bytes {
126 if set_cgroup_memory(&cgroup_full, memory_bytes).is_ok() {
127 memory_method = "cgroup";
128 tracing::debug!(
129 "Set memory limit via cgroup: {} MB ({}% of system)",
130 memory_bytes / 1024 / 1024,
131 self.max_memory_percent.unwrap_or(0)
132 );
133 }
134 }
135 if let Some(cpu_pct) = self.max_cpu_percent {
136 if set_cgroup_cpu(&cgroup_full, cpu_pct).is_ok() {
137 cpu_method = "cgroup";
138 tracing::debug!("Set CPU limit via cgroup: {}%", cpu_pct);
139 }
140 }
141 if memory_method != "cgroup" && cpu_method != "cgroup" {
143 #[allow(clippy::let_underscore_must_use)]
144 let _ = fs::remove_dir(&cgroup_full);
145 false
146 } else {
147 true
148 }
149 }
150 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
151 if max_memory_bytes.is_some() {
154 memory_method = "cgroup";
155 }
156 if self.max_cpu_percent.is_some() {
157 cpu_method = "cgroup";
158 }
159 true
160 }
161 Err(_) => false,
162 };
163
164 if cgroup_usable {
165 action.cgroup_dir = Some(cgroup_full);
166 tracing::info!(
167 "Using resource limits: memory={} ({}), CPU={} ({})",
168 self.max_memory_percent
169 .map(|p| format!("{}%", p))
170 .unwrap_or("unlimited".to_string()),
171 memory_method,
172 self.max_cpu_percent
173 .map(|c| format!("{}%", c))
174 .unwrap_or("unlimited".to_string()),
175 cpu_method
176 );
177 return Ok(action);
178 }
179 }
180
181 if memory_method != "cgroup" && max_memory_bytes.is_some() {
188 unsafe {
189 cmd.pre_exec(move || {
190 if let Some(mem_limit) = max_memory_bytes {
191 #[allow(clippy::let_underscore_must_use)]
194 let _ = apply_memory_limit_setrlimit(mem_limit);
195 }
196 Ok(())
197 });
198 }
199 memory_method = "setrlimit";
200 }
201
202 tracing::info!(
203 "Using resource limits: memory={} ({}), CPU={} ({})",
204 self.max_memory_percent
205 .map(|p| format!("{}%", p))
206 .unwrap_or("unlimited".to_string()),
207 memory_method,
208 self.max_cpu_percent
209 .map(|c| format!("{}%", c))
210 .unwrap_or("unlimited".to_string()),
211 if cpu_method == "none" {
212 "unavailable"
213 } else {
214 cpu_method
215 }
216 );
217
218 Ok(action)
219 }
220}
221
222#[cfg(target_os = "linux")]
224fn find_user_cgroup() -> Option<PathBuf> {
225 let cgroup_root = PathBuf::from("/sys/fs/cgroup");
226 if !cgroup_root.exists() {
227 tracing::debug!("cgroups v2 not available at /sys/fs/cgroup");
228 return None;
229 }
230
231 let uid = get_uid();
232
233 let locations = vec![
235 cgroup_root.join(format!(
236 "user.slice/user-{}.slice/user@{}.service/app.slice",
237 uid, uid
238 )),
239 cgroup_root.join(format!(
240 "user.slice/user-{}.slice/user@{}.service",
241 uid, uid
242 )),
243 cgroup_root.join(format!("user.slice/user-{}.slice", uid)),
244 cgroup_root.join(format!("user-{}", uid)),
245 ];
246
247 for parent in locations {
248 if !parent.exists() {
249 continue;
250 }
251
252 let test_file = parent.join("cgroup.procs");
254 if is_writable(&test_file) {
255 tracing::debug!("Found writable user cgroup: {:?}", parent);
256 return Some(parent);
257 }
258 }
259
260 tracing::debug!("No writable user-delegated cgroup found");
261 None
262}
263
264#[cfg(target_os = "linux")]
266fn set_cgroup_memory(cgroup_path: &Path, bytes: u64) -> io::Result<()> {
267 let memory_max_file = cgroup_path.join("memory.max");
268 fs::write(&memory_max_file, format!("{}", bytes))?;
269 Ok(())
270}
271
272#[cfg(target_os = "linux")]
274fn set_cgroup_cpu(cgroup_path: &Path, percent: u32) -> io::Result<()> {
275 let period_us = 100_000;
278 let max_us = (period_us * percent as u64) / 100;
279 let cpu_max_file = cgroup_path.join("cpu.max");
280 fs::write(&cpu_max_file, format!("{} {}", max_us, period_us))?;
281 Ok(())
282}
283
284#[cfg(target_os = "linux")]
286fn is_writable(path: &Path) -> bool {
287 use std::os::unix::fs::PermissionsExt;
288
289 if let Ok(metadata) = fs::metadata(path) {
290 let permissions = metadata.permissions();
291 permissions.mode() & 0o200 != 0
293 } else {
294 false
295 }
296}
297
298#[cfg(target_os = "linux")]
300fn get_uid() -> u32 {
301 unsafe { libc::getuid() }
302}
303
304pub struct SystemResources;
306
307impl SystemResources {
308 pub fn total_memory_mb() -> io::Result<u64> {
310 #[cfg(target_os = "linux")]
311 {
312 Self::linux_total_memory_mb()
313 }
314
315 #[cfg(not(target_os = "linux"))]
316 {
317 Err(io::Error::new(
319 io::ErrorKind::Unsupported,
320 "Memory detection not implemented for this platform",
321 ))
322 }
323 }
324
325 #[cfg(target_os = "linux")]
326 fn linux_total_memory_mb() -> io::Result<u64> {
327 let meminfo = std::fs::read_to_string("/proc/meminfo")?;
329
330 for line in meminfo.lines() {
331 if line.starts_with("MemTotal:") {
332 let parts: Vec<&str> = line.split_whitespace().collect();
334 if parts.len() >= 2 {
335 if let Ok(kb) = parts[1].parse::<u64>() {
336 return Ok(kb / 1024); }
338 }
339 }
340 }
341
342 Err(io::Error::new(
343 io::ErrorKind::InvalidData,
344 "Could not parse MemTotal from /proc/meminfo",
345 ))
346 }
347
348 pub fn cpu_count() -> io::Result<usize> {
350 #[cfg(target_os = "linux")]
351 {
352 Ok(num_cpus())
353 }
354
355 #[cfg(not(target_os = "linux"))]
356 {
357 Err(io::Error::new(
359 io::ErrorKind::Unsupported,
360 "CPU detection not implemented for this platform",
361 ))
362 }
363 }
364}
365
366#[cfg(target_os = "linux")]
368fn apply_memory_limit_setrlimit(bytes: u64) -> io::Result<()> {
369 use nix::sys::resource::{setrlimit, Resource};
370
371 let limit = bytes as nix::libc::rlim_t;
375 setrlimit(Resource::RLIMIT_AS, limit, limit)
376 .map_err(|e| io::Error::other(format!("setrlimit AS failed: {}", e)))
377}
378
379#[cfg(target_os = "linux")]
381fn num_cpus() -> usize {
382 std::thread::available_parallelism()
383 .map(|n| n.get())
384 .unwrap_or(1)
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn test_process_limits_default() {
393 let limits = ProcessLimits::default();
394
395 #[cfg(target_os = "linux")]
396 {
397 assert!(limits.enabled);
398 assert_eq!(limits.max_memory_percent, Some(50));
399 assert_eq!(limits.max_cpu_percent, Some(90));
400 }
401
402 #[cfg(not(target_os = "linux"))]
403 {
404 assert!(!limits.enabled);
405 }
406 }
407
408 #[test]
409 fn test_process_limits_unlimited() {
410 let limits = ProcessLimits::unlimited();
411 assert!(!limits.enabled);
412 assert_eq!(limits.max_memory_percent, None);
413 assert_eq!(limits.max_cpu_percent, None);
414 }
415
416 #[test]
417 fn test_process_limits_serialization() {
418 let limits = ProcessLimits {
419 max_memory_percent: Some(50),
420 max_cpu_percent: Some(80),
421 enabled: true,
422 };
423
424 let json = serde_json::to_string(&limits).unwrap();
425 let deserialized: ProcessLimits = serde_json::from_str(&json).unwrap();
426
427 assert_eq!(limits, deserialized);
428 }
429
430 #[test]
431 #[cfg(target_os = "linux")]
432 fn test_system_resources_memory() {
433 let mem_mb = SystemResources::total_memory_mb();
434 assert!(mem_mb.is_ok());
435
436 if let Ok(mem) = mem_mb {
437 assert!(mem > 0);
438 println!("Total system memory: {} MB", mem);
439 }
440 }
441
442 #[test]
443 #[cfg(target_os = "linux")]
444 fn test_system_resources_cpu() {
445 let cpu_count = SystemResources::cpu_count();
446 assert!(cpu_count.is_ok());
447
448 if let Ok(count) = cpu_count {
449 assert!(count > 0);
450 println!("Total CPU cores: {}", count);
451 }
452 }
453
454 #[test]
455 fn test_process_limits_apply_to_command_disabled() {
456 let limits = ProcessLimits::unlimited();
457 let mut cmd = tokio::process::Command::new("echo");
458
459 let result = limits.apply_to_command(&mut cmd);
461 assert!(result.is_ok());
462 }
463
464 #[test]
465 #[cfg(target_os = "linux")]
466 fn test_memory_limit_bytes_calculation() {
467 let limits = ProcessLimits {
468 max_memory_percent: Some(50),
469 max_cpu_percent: Some(90),
470 enabled: true,
471 };
472
473 let memory_bytes = limits.memory_limit_bytes();
474
475 assert!(memory_bytes.is_some());
477
478 if let Some(bytes) = memory_bytes {
479 let total_memory = SystemResources::total_memory_mb().unwrap();
481 let expected_bytes = (total_memory / 2) * 1024 * 1024;
482
483 assert!((bytes as i64 - expected_bytes as i64).abs() < 10 * 1024 * 1024);
485 }
486 }
487
488 #[test]
489 fn test_process_limits_json_with_null_memory() {
490 let json = r#"{
492 "max_memory_percent": null,
493 "max_cpu_percent": 90,
494 "enabled": true
495 }"#;
496
497 let limits: ProcessLimits = serde_json::from_str(json).unwrap();
498 assert_eq!(limits.max_memory_percent, None);
499 assert_eq!(limits.max_cpu_percent, Some(90));
500 assert!(limits.enabled);
501 }
502
503 #[tokio::test]
504 #[cfg(target_os = "linux")]
505 async fn test_spawn_process_with_limits() {
506 let limits = ProcessLimits {
508 max_memory_percent: Some(10), max_cpu_percent: Some(50),
510 enabled: true,
511 };
512
513 let mut cmd = tokio::process::Command::new("echo");
514 cmd.arg("test");
515
516 limits.apply_to_command(&mut cmd).unwrap();
518
519 let output = cmd.output().await;
521
522 assert!(output.is_ok());
524 let output = output.unwrap();
525 assert!(output.status.success());
526 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "test");
527 }
528
529 #[test]
530 #[cfg(target_os = "linux")]
531 fn test_user_cgroup_detection() {
532 let cgroup = find_user_cgroup();
534 match cgroup {
535 Some(path) => {
536 println!("✓ Found writable user cgroup at: {:?}", path);
537 }
538 None => {
539 println!("✗ No writable user cgroup found");
540 }
541 }
542 }
543
544 #[test]
545 #[cfg(target_os = "linux")]
546 fn test_memory_limit_independent() {
547 let _limits = ProcessLimits {
549 max_memory_percent: Some(10),
550 max_cpu_percent: None, enabled: true,
552 };
553
554 if let Some(cgroup) = find_user_cgroup() {
555 let test_cgroup = cgroup.join("test-memory-only");
556 if fs::create_dir(&test_cgroup).is_ok() {
557 let result = set_cgroup_memory(&test_cgroup, 100 * 1024 * 1024);
559
560 if result.is_ok() {
561 println!("✓ Memory limit works independently");
562 } else {
563 println!("✗ Memory limit failed: {:?}", result.err());
564 }
565
566 drop(fs::remove_dir(&test_cgroup));
568 }
569 } else {
570 println!("⊘ No user cgroup available for testing");
571 }
572 }
573}