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::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12
13impl ProcessLimits {
14    /// Get the memory limit in bytes, computed from percentage of total system memory
15    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    /// Apply these limits to a tokio Command before spawning
24    ///
25    /// On Linux, tries user-delegated cgroups v2, otherwise falls back to setrlimit.
26    /// Memory and CPU limits are handled independently.
27    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            // TODO: Implement for macOS using setrlimit
40            // TODO: Implement for Windows using Job Objects
41            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        // Find a user-delegated cgroup path if available
52        let cgroup_path = find_user_cgroup();
53
54        // Track what methods we'll use
55        let mut memory_method = "none";
56        let mut cpu_method = "none";
57
58        // Try to set up cgroup limits
59        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            // Try to create the cgroup directory
65            if fs::create_dir(&cgroup_full).is_ok() {
66                // Try memory limit (works without full delegation)
67                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                // Try CPU limit (requires cpu controller delegation)
79                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 we successfully set at least one limit via cgroup, use it
87                if memory_method == "cgroup" || cpu_method == "cgroup" {
88                    let cgroup_to_use = cgroup_full.clone();
89
90                    unsafe {
91                        cmd.pre_exec(move || {
92                            // Move process into the cgroup
93                            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                    // Best-effort cleanup of unused cgroup directory.
114                    #[allow(clippy::let_underscore_must_use)]
115                    let _ = fs::remove_dir(&cgroup_full);
116                }
117            }
118        }
119
120        // Fall back to setrlimit for memory if cgroup didn't work
121        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/// Find a writable user-delegated cgroup
161#[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    // Try common locations for user-delegated cgroups
172    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        // Check if we can write to this location
191        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/// Set memory limit in a cgroup (works without full delegation)
203#[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/// Set CPU limit in a cgroup (requires cpu controller delegation)
211#[cfg(target_os = "linux")]
212fn set_cgroup_cpu(cgroup_path: &Path, percent: u32) -> io::Result<()> {
213    // cpu.max format: "$MAX $PERIOD" where MAX/PERIOD = desired quota
214    // Standard period is 100ms (100000 microseconds)
215    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/// Check if a file is writable
223#[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        // Check if user has write permission
230        permissions.mode() & 0o200 != 0
231    } else {
232        false
233    }
234}
235
236/// Move the current process into a cgroup
237#[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/// Get the current user's UID
246#[cfg(target_os = "linux")]
247fn get_uid() -> u32 {
248    unsafe { libc::getuid() }
249}
250
251/// System resource information utilities
252pub struct SystemResources;
253
254impl SystemResources {
255    /// Get total system memory in megabytes
256    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            // TODO: Implement for other platforms
265            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        // Read from /proc/meminfo
275        let meminfo = std::fs::read_to_string("/proc/meminfo")?;
276
277        for line in meminfo.lines() {
278            if line.starts_with("MemTotal:") {
279                // Format: "MemTotal:       16384000 kB"
280                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); // Convert KB to MB
284                    }
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    /// Get total number of CPU cores
296    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            // TODO: Implement for other platforms
305            Err(io::Error::new(
306                io::ErrorKind::Unsupported,
307                "CPU detection not implemented for this platform",
308            ))
309        }
310    }
311}
312
313/// Apply memory limit via setrlimit (fallback method)
314#[cfg(target_os = "linux")]
315fn apply_memory_limit_setrlimit(bytes: u64) -> io::Result<()> {
316    use nix::sys::resource::{setrlimit, Resource};
317
318    // Set RLIMIT_AS (address space / virtual memory limit)
319    // On 32-bit platforms, rlim_t is u32, so we need to convert carefully.
320    // If bytes exceeds what rlim_t can represent, clamp to rlim_t::MAX.
321    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/// Get the number of CPU cores (Linux)
327#[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        // Should succeed without applying any limits
407        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        // Should be able to compute memory limit
423        assert!(memory_bytes.is_some());
424
425        if let Some(bytes) = memory_bytes {
426            // Should be approximately 50% of system memory
427            let total_memory = SystemResources::total_memory_mb().unwrap();
428            let expected_bytes = (total_memory / 2) * 1024 * 1024;
429
430            // Allow for some rounding differences
431            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        // Test that null memory value deserializes correctly
438        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        // Test that we can successfully spawn a process with limits applied
454        let limits = ProcessLimits {
455            max_memory_percent: Some(10), // 10% of system memory
456            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        // Apply limits (will try cgroups or fall back to setrlimit)
464        limits.apply_to_command(&mut cmd).unwrap();
465
466        // Spawn and wait for the process
467        let output = cmd.output().await;
468
469        // Process should succeed despite limits (echo is very lightweight)
470        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        // Check if we can find user-delegated cgroups
480        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        // Test that memory limits can be set independently
495        let _limits = ProcessLimits {
496            max_memory_percent: Some(10),
497            max_cpu_percent: None, // No CPU limit
498            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                // Try setting memory limit
505                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                // Clean up
514                drop(fs::remove_dir(&test_cgroup));
515            }
516        } else {
517            println!("⊘ No user cgroup available for testing");
518        }
519    }
520}