Skip to main content

fresh/services/
process_limits.rs

1/// Process resource limiting infrastructure
2///
3/// Provides cross-platform support for limiting memory and CPU usage of spawned processes.
4/// On Linux, uses user-delegated cgroups v2 if available, otherwise falls back to setrlimit.
5/// Memory and CPU limits are decoupled - memory can work without CPU delegation.
6// Re-export the type from the shared types module
7pub 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/// Action that must be applied to a spawned child process after `Command::spawn`
17/// returns, from the parent process.
18///
19/// This exists because moving a child into a cgroup used to be done from a
20/// `pre_exec` closure, which runs in the forked child between fork and exec.
21/// That context is effectively single-threaded and inherits locks held by
22/// other threads of the parent at fork time — any allocation (`format!`,
23/// `PathBuf::join`, `fs::write`) can deadlock on a malloc mutex that was held
24/// by another thread at fork time.  Instead, we record what needs to happen
25/// and do the work from the parent once the child has a PID.
26#[derive(Debug, Default)]
27pub struct PostSpawnAction {
28    /// Cgroup directory the child should be moved into (by writing its PID
29    /// to `<dir>/cgroup.procs`).  `None` means no cgroup move is needed.
30    #[cfg(target_os = "linux")]
31    cgroup_dir: Option<PathBuf>,
32}
33
34impl PostSpawnAction {
35    /// Apply the recorded action to a just-spawned child PID.
36    ///
37    /// Safe to call from the parent — unlike the old `pre_exec` variant, this
38    /// runs with the full runtime available (no fork-safety constraints).
39    /// Errors are logged but not returned: best-effort, because the process
40    /// is already running and we don't want to kill it just because cgroup
41    /// assignment failed.
42    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    /// Get the memory limit in bytes, computed from percentage of total system memory
60    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    /// Apply these limits to a tokio Command before spawning
69    ///
70    /// On Linux, tries user-delegated cgroups v2, otherwise falls back to setrlimit.
71    /// Memory and CPU limits are handled independently.
72    ///
73    /// Returns a `PostSpawnAction` that the caller must apply to the child
74    /// process after `cmd.spawn()` returns.  (For cgroups, the actual
75    /// per-child assignment happens from the parent using the child's PID;
76    /// doing it in `pre_exec` is not fork-safe.)
77    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            // TODO: Implement for macOS using setrlimit
93            // TODO: Implement for Windows using Job Objects
94            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        // Find a user-delegated cgroup path if available
105        let cgroup_path = find_user_cgroup();
106
107        // Track what methods we'll use
108        let mut memory_method = "none";
109        let mut cpu_method = "none";
110        let mut action = PostSpawnAction::default();
111
112        // Try to set up cgroup limits.  One cgroup per editor process; reused
113        // across all LSP spawns.  All children write their PIDs to the same
114        // `cgroup.procs` after spawn.
115        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            // create_dir returns AlreadyExists if another spawn already set it
121            // up in this process; that's fine — we'll reuse it.
122            let cgroup_usable = match fs::create_dir(&cgroup_full) {
123                Ok(()) => {
124                    // Freshly created — set limits on it.
125                    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 no limit took effect, drop the empty cgroup.
142                    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                    // Already set up by a previous spawn in this process.
152                    // Assume the limits were applied then; reuse the cgroup.
153                    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        // Fall back to setrlimit for memory if cgroup didn't work.
182        //
183        // Note: `pre_exec` runs in the forked child between fork and exec, so
184        // it must only use async-signal-safe operations.  `setrlimit` itself
185        // is safe; we deliberately avoid any logging or allocation inside the
186        // closure to keep it fork-safe.
187        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                        // Ignore errors — we cannot safely log from a
192                        // post-fork / pre-exec context.
193                        #[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/// Find a writable user-delegated cgroup
223#[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    // Try common locations for user-delegated cgroups
234    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        // Check if we can write to this location
253        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/// Set memory limit in a cgroup (works without full delegation)
265#[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/// Set CPU limit in a cgroup (requires cpu controller delegation)
273#[cfg(target_os = "linux")]
274fn set_cgroup_cpu(cgroup_path: &Path, percent: u32) -> io::Result<()> {
275    // cpu.max format: "$MAX $PERIOD" where MAX/PERIOD = desired quota
276    // Standard period is 100ms (100000 microseconds)
277    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/// Check if a file is writable
285#[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        // Check if user has write permission
292        permissions.mode() & 0o200 != 0
293    } else {
294        false
295    }
296}
297
298/// Get the current user's UID
299#[cfg(target_os = "linux")]
300fn get_uid() -> u32 {
301    unsafe { libc::getuid() }
302}
303
304/// System resource information utilities
305pub struct SystemResources;
306
307impl SystemResources {
308    /// Get total system memory in megabytes
309    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            // TODO: Implement for other platforms
318            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        // Read from /proc/meminfo
328        let meminfo = std::fs::read_to_string("/proc/meminfo")?;
329
330        for line in meminfo.lines() {
331            if line.starts_with("MemTotal:") {
332                // Format: "MemTotal:       16384000 kB"
333                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); // Convert KB to MB
337                    }
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    /// Get total number of CPU cores
349    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            // TODO: Implement for other platforms
358            Err(io::Error::new(
359                io::ErrorKind::Unsupported,
360                "CPU detection not implemented for this platform",
361            ))
362        }
363    }
364}
365
366/// Apply memory limit via setrlimit (fallback method)
367#[cfg(target_os = "linux")]
368fn apply_memory_limit_setrlimit(bytes: u64) -> io::Result<()> {
369    use nix::sys::resource::{setrlimit, Resource};
370
371    // Set RLIMIT_AS (address space / virtual memory limit)
372    // On 32-bit platforms, rlim_t is u32, so we need to convert carefully.
373    // If bytes exceeds what rlim_t can represent, clamp to rlim_t::MAX.
374    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/// Get the number of CPU cores (Linux)
380#[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        // Should succeed without applying any limits
460        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        // Should be able to compute memory limit
476        assert!(memory_bytes.is_some());
477
478        if let Some(bytes) = memory_bytes {
479            // Should be approximately 50% of system memory
480            let total_memory = SystemResources::total_memory_mb().unwrap();
481            let expected_bytes = (total_memory / 2) * 1024 * 1024;
482
483            // Allow for some rounding differences
484            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        // Test that null memory value deserializes correctly
491        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        // Test that we can successfully spawn a process with limits applied
507        let limits = ProcessLimits {
508            max_memory_percent: Some(10), // 10% of system memory
509            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        // Apply limits (will try cgroups or fall back to setrlimit)
517        limits.apply_to_command(&mut cmd).unwrap();
518
519        // Spawn and wait for the process
520        let output = cmd.output().await;
521
522        // Process should succeed despite limits (echo is very lightweight)
523        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        // Check if we can find user-delegated cgroups
533        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        // Test that memory limits can be set independently
548        let _limits = ProcessLimits {
549            max_memory_percent: Some(10),
550            max_cpu_percent: None, // No CPU limit
551            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                // Try setting memory limit
558                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                // Clean up
567                drop(fs::remove_dir(&test_cgroup));
568            }
569        } else {
570            println!("⊘ No user cgroup available for testing");
571        }
572    }
573}