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
16impl ProcessLimits {
17    /// Get the memory limit in bytes, computed from percentage of total system memory
18    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    /// Apply these limits to a tokio Command before spawning
27    ///
28    /// On Linux, tries user-delegated cgroups v2, otherwise falls back to setrlimit.
29    /// Memory and CPU limits are handled independently.
30    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            // TODO: Implement for macOS using setrlimit
43            // TODO: Implement for Windows using Job Objects
44            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        // Find a user-delegated cgroup path if available
55        let cgroup_path = find_user_cgroup();
56
57        // Track what methods we'll use
58        let mut memory_method = "none";
59        let mut cpu_method = "none";
60
61        // Try to set up cgroup limits
62        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            // Try to create the cgroup directory
68            if fs::create_dir(&cgroup_full).is_ok() {
69                // Try memory limit (works without full delegation)
70                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                // Try CPU limit (requires cpu controller delegation)
82                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 we successfully set at least one limit via cgroup, use it
90                if memory_method == "cgroup" || cpu_method == "cgroup" {
91                    let cgroup_to_use = cgroup_full.clone();
92
93                    unsafe {
94                        cmd.pre_exec(move || {
95                            // Move process into the cgroup
96                            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                    // Best-effort cleanup of unused cgroup directory.
117                    #[allow(clippy::let_underscore_must_use)]
118                    let _ = fs::remove_dir(&cgroup_full);
119                }
120            }
121        }
122
123        // Fall back to setrlimit for memory if cgroup didn't work
124        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/// Find a writable user-delegated cgroup
164#[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    // Try common locations for user-delegated cgroups
175    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        // Check if we can write to this location
194        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/// Set memory limit in a cgroup (works without full delegation)
206#[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/// Set CPU limit in a cgroup (requires cpu controller delegation)
214#[cfg(target_os = "linux")]
215fn set_cgroup_cpu(cgroup_path: &Path, percent: u32) -> io::Result<()> {
216    // cpu.max format: "$MAX $PERIOD" where MAX/PERIOD = desired quota
217    // Standard period is 100ms (100000 microseconds)
218    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/// Check if a file is writable
226#[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        // Check if user has write permission
233        permissions.mode() & 0o200 != 0
234    } else {
235        false
236    }
237}
238
239/// Move the current process into a cgroup
240#[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/// Get the current user's UID
249#[cfg(target_os = "linux")]
250fn get_uid() -> u32 {
251    unsafe { libc::getuid() }
252}
253
254/// System resource information utilities
255pub struct SystemResources;
256
257impl SystemResources {
258    /// Get total system memory in megabytes
259    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            // TODO: Implement for other platforms
268            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        // Read from /proc/meminfo
278        let meminfo = std::fs::read_to_string("/proc/meminfo")?;
279
280        for line in meminfo.lines() {
281            if line.starts_with("MemTotal:") {
282                // Format: "MemTotal:       16384000 kB"
283                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); // Convert KB to MB
287                    }
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    /// Get total number of CPU cores
299    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            // TODO: Implement for other platforms
308            Err(io::Error::new(
309                io::ErrorKind::Unsupported,
310                "CPU detection not implemented for this platform",
311            ))
312        }
313    }
314}
315
316/// Apply memory limit via setrlimit (fallback method)
317#[cfg(target_os = "linux")]
318fn apply_memory_limit_setrlimit(bytes: u64) -> io::Result<()> {
319    use nix::sys::resource::{setrlimit, Resource};
320
321    // Set RLIMIT_AS (address space / virtual memory limit)
322    // On 32-bit platforms, rlim_t is u32, so we need to convert carefully.
323    // If bytes exceeds what rlim_t can represent, clamp to rlim_t::MAX.
324    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/// Get the number of CPU cores (Linux)
330#[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        // Should succeed without applying any limits
410        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        // Should be able to compute memory limit
426        assert!(memory_bytes.is_some());
427
428        if let Some(bytes) = memory_bytes {
429            // Should be approximately 50% of system memory
430            let total_memory = SystemResources::total_memory_mb().unwrap();
431            let expected_bytes = (total_memory / 2) * 1024 * 1024;
432
433            // Allow for some rounding differences
434            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        // Test that null memory value deserializes correctly
441        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        // Test that we can successfully spawn a process with limits applied
457        let limits = ProcessLimits {
458            max_memory_percent: Some(10), // 10% of system memory
459            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        // Apply limits (will try cgroups or fall back to setrlimit)
467        limits.apply_to_command(&mut cmd).unwrap();
468
469        // Spawn and wait for the process
470        let output = cmd.output().await;
471
472        // Process should succeed despite limits (echo is very lightweight)
473        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        // Check if we can find user-delegated cgroups
483        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        // Test that memory limits can be set independently
498        let _limits = ProcessLimits {
499            max_memory_percent: Some(10),
500            max_cpu_percent: None, // No CPU limit
501            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                // Try setting memory limit
508                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                // Clean up
517                drop(fs::remove_dir(&test_cgroup));
518            }
519        } else {
520            println!("⊘ No user cgroup available for testing");
521        }
522    }
523}