use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
pub struct ProcessLimits {
#[serde(default)]
pub max_memory_mb: Option<u64>,
#[serde(default)]
pub max_cpu_percent: Option<u32>,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool {
true
}
impl Default for ProcessLimits {
fn default() -> Self {
Self {
max_memory_mb: Self::default_memory_limit_mb(),
max_cpu_percent: Some(90), enabled: cfg!(target_os = "linux"), }
}
}
impl ProcessLimits {
pub fn default_memory_limit_mb() -> Option<u64> {
SystemResources::total_memory_mb()
.map(|total| total / 2) .ok()
}
pub fn default_cpu_limit_percent() -> u32 {
90
}
pub fn unlimited() -> Self {
Self {
max_memory_mb: None,
max_cpu_percent: None,
enabled: false,
}
}
pub fn apply_to_command(&self, cmd: &mut tokio::process::Command) -> io::Result<()> {
if !self.enabled {
return Ok(());
}
#[cfg(target_os = "linux")]
{
self.apply_linux_limits(cmd)
}
#[cfg(not(target_os = "linux"))]
{
tracing::warn!("Process resource limits are not yet implemented for this platform");
Ok(())
}
}
#[cfg(target_os = "linux")]
fn apply_linux_limits(&self, cmd: &mut tokio::process::Command) -> io::Result<()> {
let max_memory_bytes = self.max_memory_mb.map(|mb| mb * 1024 * 1024);
let _max_cpu_percent = self.max_cpu_percent;
let cgroup_path = find_user_cgroup();
let mut memory_method = "none";
let mut cpu_method = "none";
if let Some(ref cgroup_base) = cgroup_path {
let pid = std::process::id();
let cgroup_name = format!("editor-lsp-{}", pid);
let cgroup_full = cgroup_base.join(&cgroup_name);
if fs::create_dir(&cgroup_full).is_ok() {
if let Some(memory_mb) = self.max_memory_mb {
let memory_bytes = memory_mb * 1024 * 1024;
if set_cgroup_memory(&cgroup_full, memory_bytes).is_ok() {
memory_method = "cgroup";
tracing::debug!("Set memory limit via cgroup: {} MB", memory_mb);
}
}
if let Some(cpu_pct) = self.max_cpu_percent {
if set_cgroup_cpu(&cgroup_full, cpu_pct).is_ok() {
cpu_method = "cgroup";
tracing::debug!("Set CPU limit via cgroup: {}%", cpu_pct);
}
}
if memory_method == "cgroup" || cpu_method == "cgroup" {
let cgroup_to_use = cgroup_full.clone();
unsafe {
cmd.pre_exec(move || {
if let Err(e) = move_to_cgroup(&cgroup_to_use) {
tracing::warn!("Failed to move process to cgroup: {}", e);
}
Ok(())
});
}
tracing::info!(
"Using resource limits: memory={} ({}), CPU={} ({})",
self.max_memory_mb
.map(|m| format!("{} MB", m))
.unwrap_or("unlimited".to_string()),
memory_method,
self.max_cpu_percent
.map(|c| format!("{}%", c))
.unwrap_or("unlimited".to_string()),
cpu_method
);
return Ok(());
} else {
let _ = fs::remove_dir(&cgroup_full);
}
}
}
if memory_method != "cgroup" && max_memory_bytes.is_some() {
unsafe {
cmd.pre_exec(move || {
if let Some(mem_limit) = max_memory_bytes {
if let Err(e) = apply_memory_limit_setrlimit(mem_limit) {
tracing::warn!("Failed to apply memory limit via setrlimit: {}", e);
} else {
tracing::debug!(
"Applied memory limit via setrlimit: {} MB",
mem_limit / 1024 / 1024
);
}
}
Ok(())
});
}
memory_method = "setrlimit";
}
tracing::info!(
"Using resource limits: memory={} ({}), CPU={} ({})",
self.max_memory_mb
.map(|m| format!("{} MB", m))
.unwrap_or("unlimited".to_string()),
memory_method,
self.max_cpu_percent
.map(|c| format!("{}%", c))
.unwrap_or("unlimited".to_string()),
if cpu_method == "none" {
"unavailable"
} else {
cpu_method
}
);
Ok(())
}
}
#[cfg(target_os = "linux")]
fn find_user_cgroup() -> Option<PathBuf> {
let cgroup_root = PathBuf::from("/sys/fs/cgroup");
if !cgroup_root.exists() {
tracing::debug!("cgroups v2 not available at /sys/fs/cgroup");
return None;
}
let uid = get_uid();
let locations = vec![
cgroup_root.join(format!(
"user.slice/user-{}.slice/user@{}.service/app.slice",
uid, uid
)),
cgroup_root.join(format!(
"user.slice/user-{}.slice/user@{}.service",
uid, uid
)),
cgroup_root.join(format!("user.slice/user-{}.slice", uid)),
cgroup_root.join(format!("user-{}", uid)),
];
for parent in locations {
if !parent.exists() {
continue;
}
let test_file = parent.join("cgroup.procs");
if is_writable(&test_file) {
tracing::debug!("Found writable user cgroup: {:?}", parent);
return Some(parent);
}
}
tracing::debug!("No writable user-delegated cgroup found");
None
}
#[cfg(target_os = "linux")]
fn set_cgroup_memory(cgroup_path: &PathBuf, bytes: u64) -> io::Result<()> {
let memory_max_file = cgroup_path.join("memory.max");
fs::write(&memory_max_file, format!("{}", bytes))?;
Ok(())
}
#[cfg(target_os = "linux")]
fn set_cgroup_cpu(cgroup_path: &PathBuf, percent: u32) -> io::Result<()> {
let period_us = 100_000;
let max_us = (period_us * percent as u64) / 100;
let cpu_max_file = cgroup_path.join("cpu.max");
fs::write(&cpu_max_file, format!("{} {}", max_us, period_us))?;
Ok(())
}
#[cfg(target_os = "linux")]
fn is_writable(path: &PathBuf) -> bool {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = fs::metadata(path) {
let permissions = metadata.permissions();
permissions.mode() & 0o200 != 0
} else {
false
}
}
#[cfg(target_os = "linux")]
fn move_to_cgroup(cgroup_path: &PathBuf) -> io::Result<()> {
let procs_file = cgroup_path.join("cgroup.procs");
let pid = std::process::id();
fs::write(&procs_file, format!("{}", pid))?;
Ok(())
}
#[cfg(target_os = "linux")]
fn get_uid() -> u32 {
unsafe { libc::getuid() }
}
pub struct SystemResources;
impl SystemResources {
pub fn total_memory_mb() -> io::Result<u64> {
#[cfg(target_os = "linux")]
{
Self::linux_total_memory_mb()
}
#[cfg(not(target_os = "linux"))]
{
Err(io::Error::new(
io::ErrorKind::Unsupported,
"Memory detection not implemented for this platform",
))
}
}
#[cfg(target_os = "linux")]
fn linux_total_memory_mb() -> io::Result<u64> {
let meminfo = std::fs::read_to_string("/proc/meminfo")?;
for line in meminfo.lines() {
if line.starts_with("MemTotal:") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let Ok(kb) = parts[1].parse::<u64>() {
return Ok(kb / 1024); }
}
}
}
Err(io::Error::new(
io::ErrorKind::InvalidData,
"Could not parse MemTotal from /proc/meminfo",
))
}
pub fn cpu_count() -> io::Result<usize> {
#[cfg(target_os = "linux")]
{
Ok(num_cpus())
}
#[cfg(not(target_os = "linux"))]
{
Err(io::Error::new(
io::ErrorKind::Unsupported,
"CPU detection not implemented for this platform",
))
}
}
}
#[cfg(target_os = "linux")]
fn apply_memory_limit_setrlimit(bytes: u64) -> io::Result<()> {
use nix::sys::resource::{setrlimit, Resource};
setrlimit(Resource::RLIMIT_AS, bytes, bytes)
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("setrlimit AS failed: {}", e)))
}
#[cfg(target_os = "linux")]
fn num_cpus() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_process_limits_default() {
let limits = ProcessLimits::default();
#[cfg(target_os = "linux")]
{
assert!(limits.enabled);
assert!(limits.max_memory_mb.is_some());
assert_eq!(limits.max_cpu_percent, Some(90));
}
#[cfg(not(target_os = "linux"))]
{
assert!(!limits.enabled);
}
}
#[test]
fn test_process_limits_unlimited() {
let limits = ProcessLimits::unlimited();
assert!(!limits.enabled);
assert_eq!(limits.max_memory_mb, None);
assert_eq!(limits.max_cpu_percent, None);
}
#[test]
fn test_process_limits_serialization() {
let limits = ProcessLimits {
max_memory_mb: Some(1024),
max_cpu_percent: Some(80),
enabled: true,
};
let json = serde_json::to_string(&limits).unwrap();
let deserialized: ProcessLimits = serde_json::from_str(&json).unwrap();
assert_eq!(limits, deserialized);
}
#[test]
#[cfg(target_os = "linux")]
fn test_system_resources_memory() {
let mem_mb = SystemResources::total_memory_mb();
assert!(mem_mb.is_ok());
if let Ok(mem) = mem_mb {
assert!(mem > 0);
println!("Total system memory: {} MB", mem);
}
}
#[test]
#[cfg(target_os = "linux")]
fn test_system_resources_cpu() {
let cpu_count = SystemResources::cpu_count();
assert!(cpu_count.is_ok());
if let Ok(count) = cpu_count {
assert!(count > 0);
println!("Total CPU cores: {}", count);
}
}
#[test]
fn test_process_limits_apply_to_command_disabled() {
let limits = ProcessLimits::unlimited();
let mut cmd = tokio::process::Command::new("echo");
let result = limits.apply_to_command(&mut cmd);
assert!(result.is_ok());
}
#[test]
#[cfg(target_os = "linux")]
fn test_process_limits_default_memory_calculation() {
let default_memory = ProcessLimits::default_memory_limit_mb();
assert!(default_memory.is_some());
if let Some(mem_mb) = default_memory {
assert!(mem_mb > 0);
assert!(mem_mb < 1_000_000);
let total_memory = SystemResources::total_memory_mb().unwrap();
let expected = total_memory / 2;
assert!((mem_mb as i64 - expected as i64).abs() < 10);
}
}
#[test]
fn test_process_limits_json_with_null_memory() {
let json = r#"{
"max_memory_mb": null,
"max_cpu_percent": 90,
"enabled": true
}"#;
let limits: ProcessLimits = serde_json::from_str(json).unwrap();
assert_eq!(limits.max_memory_mb, None);
assert_eq!(limits.max_cpu_percent, Some(90));
assert!(limits.enabled);
}
#[tokio::test]
#[cfg(target_os = "linux")]
async fn test_spawn_process_with_limits() {
let limits = ProcessLimits {
max_memory_mb: Some(100),
max_cpu_percent: Some(50),
enabled: true,
};
let mut cmd = tokio::process::Command::new("echo");
cmd.arg("test");
limits.apply_to_command(&mut cmd).unwrap();
let output = cmd.output().await;
assert!(output.is_ok());
let output = output.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "test");
}
#[test]
#[cfg(target_os = "linux")]
fn test_user_cgroup_detection() {
let cgroup = find_user_cgroup();
match cgroup {
Some(path) => {
println!("✓ Found writable user cgroup at: {:?}", path);
}
None => {
println!("✗ No writable user cgroup found");
}
}
}
#[test]
#[cfg(target_os = "linux")]
fn test_memory_limit_independent() {
let _limits = ProcessLimits {
max_memory_mb: Some(100),
max_cpu_percent: None, enabled: true,
};
if let Some(cgroup) = find_user_cgroup() {
let test_cgroup = cgroup.join("test-memory-only");
if fs::create_dir(&test_cgroup).is_ok() {
let result = set_cgroup_memory(&test_cgroup, 100 * 1024 * 1024);
if result.is_ok() {
println!("✓ Memory limit works independently");
} else {
println!("✗ Memory limit failed: {:?}", result.err());
}
let _ = fs::remove_dir(&test_cgroup);
}
} else {
println!("⊘ No user cgroup available for testing");
}
}
}