Skip to main content

nucleus/resources/
cgroup.rs

1use crate::error::{NucleusError, Result, StateTransition};
2use crate::resources::{CgroupState, ResourceLimits};
3use nix::sys::signal::{kill, Signal};
4use nix::unistd::Pid;
5use std::ffi::{CString, OsString};
6use std::fs;
7use std::io::Write;
8use std::mem::MaybeUninit;
9use std::os::unix::ffi::OsStrExt;
10use std::os::unix::fs::OpenOptionsExt;
11use std::os::unix::io::{AsRawFd, RawFd};
12use std::path::{Component, Path, PathBuf};
13use std::thread;
14use std::time::Duration;
15use tracing::{debug, info, warn};
16
17const CGROUP_V2_ROOT: &str = "/sys/fs/cgroup";
18const CGROUP2_SUPER_MAGIC: libc::c_long = 0x6367_7270;
19const NUCLEUS_CGROUP_ROOT_ENV: &str = "NUCLEUS_CGROUP_ROOT";
20const CGROUP_CLEANUP_RETRIES: usize = 50;
21const CGROUP_CLEANUP_SLEEP: Duration = Duration::from_millis(20);
22
23/// Cgroup v2 manager
24///
25/// Implements the cgroup lifecycle state machine from
26/// Nucleus_Resources_CgroupLifecycle.tla
27pub struct Cgroup {
28    path: PathBuf,
29    state: CgroupState,
30}
31
32impl Cgroup {
33    /// Create a new cgroup with the given name
34    ///
35    /// State transition: Nonexistent -> Created
36    pub fn create(name: &str) -> Result<Self> {
37        let root = Self::root_path()?;
38        Self::create_in_root(name, &root)
39    }
40
41    fn create_in_root(name: &str, root: &Path) -> Result<Self> {
42        Self::validate_cgroup_name(name)?;
43        Self::validate_root_path(root)?;
44
45        let state = CgroupState::Nonexistent.transition(CgroupState::Created)?;
46        let path = root.join(name);
47
48        info!("Creating cgroup at {:?}", path);
49
50        // Create cgroup directory
51        fs::create_dir_all(&path).map_err(|e| {
52            NucleusError::CgroupError(format!("Failed to create cgroup directory: {}", e))
53        })?;
54
55        Self::validate_cgroup_directory(&path)?;
56
57        Ok(Self { path, state })
58    }
59
60    fn root_path() -> Result<PathBuf> {
61        let path = Self::root_path_from_override(std::env::var_os(NUCLEUS_CGROUP_ROOT_ENV))?;
62        Self::validate_root_path(&path)?;
63        Ok(path)
64    }
65
66    fn root_path_from_override(raw: Option<OsString>) -> Result<PathBuf> {
67        match raw {
68            Some(raw) if !raw.as_os_str().is_empty() => {
69                let path = PathBuf::from(raw);
70                if !path.is_absolute() {
71                    return Err(NucleusError::CgroupError(format!(
72                        "{} must be an absolute path",
73                        NUCLEUS_CGROUP_ROOT_ENV
74                    )));
75                }
76                Ok(path)
77            }
78            _ => Ok(PathBuf::from(CGROUP_V2_ROOT)),
79        }
80    }
81
82    fn validate_cgroup_name(name: &str) -> Result<()> {
83        if name.is_empty() || name.as_bytes().contains(&0) {
84            return Err(NucleusError::CgroupError(
85                "cgroup name must be a non-empty path component".to_string(),
86            ));
87        }
88
89        let mut components = Path::new(name).components();
90        match (components.next(), components.next()) {
91            (Some(Component::Normal(_)), None) => Ok(()),
92            _ => Err(NucleusError::CgroupError(
93                "cgroup name must be a single relative path component".to_string(),
94            )),
95        }
96    }
97
98    fn validate_root_path(path: &Path) -> Result<()> {
99        if !path.is_absolute() {
100            return Err(NucleusError::CgroupError(format!(
101                "{} must be an absolute path",
102                NUCLEUS_CGROUP_ROOT_ENV
103            )));
104        }
105
106        Self::validate_directory_not_symlink(path, "cgroup root")?;
107        let canonical = fs::canonicalize(path).map_err(|e| {
108            NucleusError::CgroupError(format!(
109                "Failed to canonicalize cgroup root {:?}: {}",
110                path, e
111            ))
112        })?;
113        let canonical_cgroup_root = fs::canonicalize(CGROUP_V2_ROOT).map_err(|e| {
114            NucleusError::CgroupError(format!(
115                "Failed to canonicalize {} while validating cgroup root {:?}: {}",
116                CGROUP_V2_ROOT, path, e
117            ))
118        })?;
119
120        if !canonical.starts_with(&canonical_cgroup_root) {
121            return Err(NucleusError::CgroupError(format!(
122                "cgroup root {:?} must be inside {}",
123                path, CGROUP_V2_ROOT
124            )));
125        }
126
127        Self::ensure_path_on_cgroup2_fs(&canonical)?;
128        Self::require_cgroup_control_file(&canonical.join("cgroup.controllers"))?;
129        Self::require_cgroup_control_file(&canonical.join("cgroup.subtree_control"))?;
130        Self::require_cgroup_control_file(&canonical.join("cgroup.procs"))?;
131        Ok(())
132    }
133
134    fn validate_cgroup_directory(path: &Path) -> Result<()> {
135        Self::validate_directory_not_symlink(path, "cgroup directory")?;
136        Self::ensure_path_on_cgroup2_fs(path)?;
137        Self::require_cgroup_control_file(&path.join("cgroup.procs"))?;
138        Ok(())
139    }
140
141    fn validate_directory_not_symlink(path: &Path, description: &str) -> Result<()> {
142        let metadata = fs::symlink_metadata(path).map_err(|e| {
143            NucleusError::CgroupError(format!(
144                "Failed to inspect {} {:?}: {}",
145                description, path, e
146            ))
147        })?;
148        let file_type = metadata.file_type();
149        if file_type.is_symlink() {
150            return Err(NucleusError::CgroupError(format!(
151                "{} {:?} must not be a symlink",
152                description, path
153            )));
154        }
155        if !file_type.is_dir() {
156            return Err(NucleusError::CgroupError(format!(
157                "{} {:?} must be a directory",
158                description, path
159            )));
160        }
161        Ok(())
162    }
163
164    fn require_cgroup_control_file(path: &Path) -> Result<()> {
165        let metadata = fs::symlink_metadata(path).map_err(|e| {
166            NucleusError::CgroupError(format!(
167                "Required cgroup control file {:?} is missing or inaccessible: {}",
168                path, e
169            ))
170        })?;
171        let file_type = metadata.file_type();
172        if file_type.is_symlink() {
173            return Err(NucleusError::CgroupError(format!(
174                "cgroup control file {:?} must not be a symlink",
175                path
176            )));
177        }
178        if !file_type.is_file() {
179            return Err(NucleusError::CgroupError(format!(
180                "{:?} is not a cgroup control file",
181                path
182            )));
183        }
184        Self::ensure_path_on_cgroup2_fs(path)
185    }
186
187    fn ensure_path_on_cgroup2_fs(path: &Path) -> Result<()> {
188        let statfs = Self::statfs_path(path)?;
189        Self::ensure_cgroup2_magic(statfs.f_type, path)
190    }
191
192    fn ensure_fd_on_cgroup2_fs(fd: RawFd, path: &Path) -> Result<()> {
193        let mut statfs = MaybeUninit::<libc::statfs>::uninit();
194        // SAFETY: `statfs` points to valid uninitialized storage and `fd` is
195        // borrowed from an open file for the duration of this call.
196        let rc = unsafe { libc::fstatfs(fd, statfs.as_mut_ptr()) };
197        if rc != 0 {
198            return Err(NucleusError::CgroupError(format!(
199                "Failed to statfs opened cgroup control file {:?}: {}",
200                path,
201                std::io::Error::last_os_error()
202            )));
203        }
204        // SAFETY: `fstatfs` returned success, so the kernel initialized the
205        // struct.
206        let statfs = unsafe { statfs.assume_init() };
207        Self::ensure_cgroup2_magic(statfs.f_type, path)
208    }
209
210    fn statfs_path(path: &Path) -> Result<libc::statfs> {
211        let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
212            NucleusError::CgroupError(format!("cgroup path {:?} contains an interior NUL", path))
213        })?;
214        let mut statfs = MaybeUninit::<libc::statfs>::uninit();
215        // SAFETY: `c_path` is a NUL-terminated path and `statfs` points to
216        // valid uninitialized storage for the kernel to fill.
217        let rc = unsafe { libc::statfs(c_path.as_ptr(), statfs.as_mut_ptr()) };
218        if rc != 0 {
219            return Err(NucleusError::CgroupError(format!(
220                "Failed to statfs cgroup path {:?}: {}",
221                path,
222                std::io::Error::last_os_error()
223            )));
224        }
225        // SAFETY: `statfs` returned success, so the kernel initialized the
226        // struct.
227        Ok(unsafe { statfs.assume_init() })
228    }
229
230    fn ensure_cgroup2_magic(fs_type: libc::c_long, path: &Path) -> Result<()> {
231        if fs_type != CGROUP2_SUPER_MAGIC {
232            return Err(NucleusError::CgroupError(format!(
233                "{:?} is not on a cgroup v2 filesystem",
234                path
235            )));
236        }
237        Ok(())
238    }
239
240    /// Set resource limits
241    ///
242    /// State transition: Created -> Configured
243    pub fn set_limits(&mut self, limits: &ResourceLimits) -> Result<()> {
244        self.state = self.state.transition(CgroupState::Configured)?;
245
246        info!("Configuring cgroup limits: {:?}", limits);
247
248        // Set memory limit
249        if let Some(memory_bytes) = limits.memory_bytes {
250            self.write_value("memory.max", &memory_bytes.to_string())?;
251            debug!("Set memory.max = {}", memory_bytes);
252        }
253
254        // Set memory soft limit (high watermark)
255        if let Some(memory_high) = limits.memory_high {
256            self.write_value("memory.high", &memory_high.to_string())?;
257            debug!("Set memory.high = {}", memory_high);
258        }
259
260        // Set swap limit
261        if let Some(swap_max) = limits.memory_swap_max {
262            self.write_value("memory.swap.max", &swap_max.to_string())?;
263            debug!("Set memory.swap.max = {}", swap_max);
264        }
265        if limits.memory_bytes.is_some()
266            || limits.memory_high.is_some()
267            || limits.memory_swap_max.is_some()
268        {
269            self.write_value("memory.oom.group", "1")?;
270            debug!("Set memory.oom.group = 1");
271        }
272
273        // Set CPU limit
274        if let Some(cpu_quota_us) = limits.cpu_quota_us {
275            let cpu_max = format!("{} {}", cpu_quota_us, limits.cpu_period_us);
276            self.write_value("cpu.max", &cpu_max)?;
277            debug!("Set cpu.max = {}", cpu_max);
278        }
279
280        // Set CPU weight
281        if let Some(cpu_weight) = limits.cpu_weight {
282            self.write_value("cpu.weight", &cpu_weight.to_string())?;
283            debug!("Set cpu.weight = {}", cpu_weight);
284        }
285
286        // Set PID limit
287        if let Some(pids_max) = limits.pids_max {
288            self.write_value("pids.max", &pids_max.to_string())?;
289            debug!("Set pids.max = {}", pids_max);
290        }
291
292        // Set I/O limits
293        for io_limit in &limits.io_limits {
294            let line = io_limit.to_io_max_line();
295            self.write_value("io.max", &line)?;
296            debug!("Set io.max: {}", line);
297        }
298
299        info!("Successfully configured cgroup limits");
300
301        Ok(())
302    }
303
304    /// Attach a process to this cgroup
305    ///
306    /// State transition: Configured -> Attached
307    pub fn attach_process(&mut self, pid: u32) -> Result<()> {
308        self.state = self.state.transition(CgroupState::Attached)?;
309
310        info!("Attaching process {} to cgroup", pid);
311
312        self.write_value("cgroup.procs", &pid.to_string())?;
313
314        info!("Successfully attached process to cgroup");
315
316        Ok(())
317    }
318
319    /// Write a value to a cgroup file
320    fn write_value(&self, file: &str, value: &str) -> Result<()> {
321        Self::validate_cgroup_name(file)?;
322        let file_path = self.path.join(file);
323        let mut control_file = fs::OpenOptions::new()
324            .write(true)
325            .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
326            .open(&file_path)
327            .map_err(|e| {
328                NucleusError::CgroupError(format!(
329                    "Failed to open cgroup control file {:?}: {}",
330                    file_path, e
331                ))
332            })?;
333        Self::ensure_fd_on_cgroup2_fs(control_file.as_raw_fd(), &file_path)?;
334        control_file.write_all(value.as_bytes()).map_err(|e| {
335            NucleusError::CgroupError(format!(
336                "Failed to write {} to {:?}: {}",
337                value, file_path, e
338            ))
339        })?;
340        Ok(())
341    }
342
343    /// Read a value from a cgroup file
344    fn read_value(&self, file: &str) -> Result<String> {
345        let file_path = self.path.join(file);
346        fs::read_to_string(&file_path).map_err(|e| {
347            NucleusError::CgroupError(format!("Failed to read {:?}: {}", file_path, e))
348        })
349    }
350
351    fn set_frozen(&self, frozen: bool) -> Result<bool> {
352        let freeze_path = self.path.join("cgroup.freeze");
353        if !freeze_path.exists() {
354            return Ok(false);
355        }
356        self.write_value("cgroup.freeze", if frozen { "1" } else { "0" })?;
357        debug!("Set cgroup.freeze = {}", if frozen { 1 } else { 0 });
358        Ok(true)
359    }
360
361    fn parse_cgroup_events_populated(events: &str) -> Result<bool> {
362        for line in events.lines() {
363            if let Some(value) = line.strip_prefix("populated ") {
364                return match value.trim() {
365                    "0" => Ok(false),
366                    "1" => Ok(true),
367                    other => Err(NucleusError::CgroupError(format!(
368                        "Unexpected populated value in cgroup.events: {}",
369                        other
370                    ))),
371                };
372            }
373        }
374        Err(NucleusError::CgroupError(
375            "Missing populated entry in cgroup.events".to_string(),
376        ))
377    }
378
379    fn read_pids(&self) -> Result<Vec<Pid>> {
380        let file_path = self.path.join("cgroup.procs");
381        if !file_path.exists() {
382            return Ok(Vec::new());
383        }
384        let content = fs::read_to_string(&file_path).map_err(|e| {
385            NucleusError::CgroupError(format!("Failed to read {:?}: {}", file_path, e))
386        })?;
387        content
388            .lines()
389            .filter(|line| !line.trim().is_empty())
390            .map(|line| {
391                line.trim().parse::<i32>().map(Pid::from_raw).map_err(|e| {
392                    NucleusError::CgroupError(format!(
393                        "Failed to parse pid '{}' from {:?}: {}",
394                        line.trim(),
395                        file_path,
396                        e
397                    ))
398                })
399            })
400            .collect()
401    }
402
403    fn is_populated(&self) -> Result<bool> {
404        let events_path = self.path.join("cgroup.events");
405        if events_path.exists() {
406            let events = fs::read_to_string(&events_path).map_err(|e| {
407                NucleusError::CgroupError(format!("Failed to read {:?}: {}", events_path, e))
408            })?;
409            return Self::parse_cgroup_events_populated(&events);
410        }
411        Ok(!self.read_pids()?.is_empty())
412    }
413
414    fn kill_visible_processes(&self) -> Result<()> {
415        for pid in self.read_pids()? {
416            match kill(pid, Signal::SIGKILL) {
417                Ok(()) => {}
418                Err(nix::errno::Errno::ESRCH) => {}
419                Err(e) => {
420                    return Err(NucleusError::CgroupError(format!(
421                        "Failed to SIGKILL pid {} in {:?}: {}",
422                        pid, self.path, e
423                    )))
424                }
425            }
426        }
427        Ok(())
428    }
429
430    fn kill_all_processes(&self) -> Result<()> {
431        let kill_path = self.path.join("cgroup.kill");
432        if kill_path.exists() {
433            self.write_value("cgroup.kill", "1")?;
434            debug!("Triggered cgroup.kill for {:?}", self.path);
435        }
436        self.kill_visible_processes()
437    }
438
439    fn wait_until_empty(&self) -> Result<()> {
440        for attempt in 0..CGROUP_CLEANUP_RETRIES {
441            if !self.is_populated()? {
442                return Ok(());
443            }
444            if attempt + 1 < CGROUP_CLEANUP_RETRIES {
445                self.kill_visible_processes()?;
446                thread::sleep(CGROUP_CLEANUP_SLEEP);
447            }
448        }
449
450        let remaining = self
451            .read_pids()?
452            .into_iter()
453            .map(|pid| pid.to_string())
454            .collect::<Vec<_>>();
455        Err(NucleusError::CgroupError(format!(
456            "Timed out waiting for cgroup {:?} to drain (remaining pids: {})",
457            self.path,
458            if remaining.is_empty() {
459                "<unknown>".to_string()
460            } else {
461                remaining.join(", ")
462            }
463        )))
464    }
465
466    /// Get current memory usage
467    pub fn memory_current(&self) -> Result<u64> {
468        let value = self.read_value("memory.current")?;
469        value.trim().parse().map_err(|e| {
470            NucleusError::CgroupError(format!("Failed to parse memory.current: {}", e))
471        })
472    }
473
474    /// Get cgroup path
475    pub fn path(&self) -> &Path {
476        &self.path
477    }
478
479    /// Get the current state of this cgroup
480    pub fn state(&self) -> CgroupState {
481        self.state
482    }
483
484    /// Clean up the cgroup
485    ///
486    /// State transition: * -> Removed (only on success)
487    pub fn cleanup(mut self) -> Result<()> {
488        info!("Cleaning up cgroup {:?}", self.path);
489
490        if self.path.exists() {
491            let froze = self.set_frozen(true)?;
492            let cleanup_result: Result<()> = (|| {
493                self.kill_all_processes()?;
494                self.wait_until_empty()?;
495                fs::remove_dir(&self.path).map_err(|e| {
496                    // BUG-06: Do NOT set state to Removed on failure – Drop should
497                    // still attempt cleanup when the Cgroup is dropped.
498                    NucleusError::CgroupError(format!("Failed to remove cgroup: {}", e))
499                })?;
500                Ok(())
501            })();
502            if cleanup_result.is_err() && froze {
503                if let Err(e) = self.set_frozen(false) {
504                    warn!(
505                        "Failed to unfreeze cgroup {:?} after cleanup error: {}",
506                        self.path, e
507                    );
508                }
509            }
510            cleanup_result?;
511        }
512
513        // Only mark as terminal after successful removal
514        self.state = CgroupState::Removed;
515        info!("Successfully cleaned up cgroup");
516
517        Ok(())
518    }
519}
520
521impl Drop for Cgroup {
522    fn drop(&mut self) {
523        if !self.state.is_terminal() && self.path.exists() {
524            let froze = self.set_frozen(true).unwrap_or(false);
525            let _ = self.kill_all_processes();
526            let _ = self.wait_until_empty();
527            let _ = fs::remove_dir(&self.path);
528            if self.path.exists() && froze {
529                let _ = self.set_frozen(false);
530            }
531        }
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use std::ffi::OsString;
539    use std::os::unix::fs::symlink;
540    use std::sync::Mutex;
541
542    static CGROUP_ENV_LOCK: Mutex<()> = Mutex::new(());
543
544    #[test]
545    fn test_resource_limits_unlimited() {
546        let limits = ResourceLimits::unlimited();
547        assert!(limits.memory_bytes.is_none());
548        assert!(limits.memory_high.is_none());
549        assert!(limits.memory_swap_max.is_none());
550        assert!(limits.cpu_quota_us.is_none());
551        assert!(limits.cpu_weight.is_none());
552        assert!(limits.pids_max.is_none());
553        assert!(limits.io_limits.is_empty());
554    }
555
556    #[test]
557    fn test_cgroup_root_override_requires_absolute_path() {
558        assert_eq!(
559            Cgroup::root_path_from_override(None).unwrap(),
560            PathBuf::from(CGROUP_V2_ROOT)
561        );
562        assert_eq!(
563            Cgroup::root_path_from_override(Some(OsString::from(""))).unwrap(),
564            PathBuf::from(CGROUP_V2_ROOT)
565        );
566        assert_eq!(
567            Cgroup::root_path_from_override(Some(OsString::from("/sys/fs/cgroup/example.service")))
568                .unwrap(),
569            PathBuf::from("/sys/fs/cgroup/example.service")
570        );
571        assert!(Cgroup::root_path_from_override(Some(OsString::from("relative"))).is_err());
572    }
573
574    #[test]
575    fn test_cgroup_name_must_be_single_path_component() {
576        assert!(Cgroup::validate_cgroup_name("nucleus-abc123").is_ok());
577        assert!(Cgroup::validate_cgroup_name("").is_err());
578        assert!(Cgroup::validate_cgroup_name("../escape").is_err());
579        assert!(Cgroup::validate_cgroup_name("/sys/fs/cgroup/escape").is_err());
580        assert!(Cgroup::validate_cgroup_name("parent/child").is_err());
581    }
582
583    #[test]
584    fn test_cgroup_root_validation_rejects_regular_filesystem() {
585        let temp = tempfile::tempdir().unwrap();
586
587        assert!(Cgroup::validate_root_path(temp.path()).is_err());
588    }
589
590    #[test]
591    fn test_create_rejects_regular_filesystem_root_before_child_creation() {
592        let temp = tempfile::tempdir().unwrap();
593        let child = temp.path().join("nucleus-bypass");
594
595        assert!(Cgroup::create_in_root("nucleus-bypass", temp.path()).is_err());
596        assert!(
597            !child.exists(),
598            "regular filesystem root must be rejected before creating a fake cgroup"
599        );
600    }
601
602    #[test]
603    fn test_cgroup_root_env_rejects_regular_filesystem() {
604        let _guard = CGROUP_ENV_LOCK.lock().unwrap();
605        let previous = std::env::var_os(NUCLEUS_CGROUP_ROOT_ENV);
606        let temp = tempfile::tempdir().unwrap();
607        let child = temp.path().join("nucleus-bypass");
608
609        std::env::set_var(NUCLEUS_CGROUP_ROOT_ENV, temp.path().as_os_str());
610        let result = Cgroup::create("nucleus-bypass");
611        match previous {
612            Some(value) => std::env::set_var(NUCLEUS_CGROUP_ROOT_ENV, value),
613            None => std::env::remove_var(NUCLEUS_CGROUP_ROOT_ENV),
614        }
615
616        assert!(result.is_err());
617        assert!(
618            !child.exists(),
619            "regular filesystem override must be rejected before creating a fake cgroup"
620        );
621    }
622
623    #[test]
624    fn test_write_value_rejects_preexisting_regular_file() {
625        let temp = tempfile::tempdir().unwrap();
626        let path = temp.path().join("fake-cgroup");
627        fs::create_dir(&path).unwrap();
628        let control_file = path.join("memory.max");
629        fs::write(&control_file, "old").unwrap();
630
631        let cgroup = Cgroup {
632            path,
633            state: CgroupState::Removed,
634        };
635        assert!(cgroup.write_value("memory.max", "123").is_err());
636        assert_eq!(fs::read_to_string(control_file).unwrap(), "old");
637    }
638
639    #[test]
640    fn test_write_value_rejects_symlink_control_file() {
641        let temp = tempfile::tempdir().unwrap();
642        let path = temp.path().join("fake-cgroup");
643        let target = temp.path().join("target");
644        fs::create_dir(&path).unwrap();
645        fs::write(&target, "old").unwrap();
646        symlink(&target, path.join("memory.max")).unwrap();
647
648        let cgroup = Cgroup {
649            path,
650            state: CgroupState::Removed,
651        };
652        assert!(cgroup.write_value("memory.max", "123").is_err());
653        assert_eq!(fs::read_to_string(target).unwrap(), "old");
654    }
655
656    // Note: Testing actual cgroup operations requires root privileges
657    // and cgroup v2 filesystem. These are tested in integration tests.
658
659    #[test]
660    fn test_cleanup_sets_removed_only_after_success() {
661        // BUG-06: cleanup must not mark state as Removed before the directory
662        // is actually removed. Verify structurally by brace-matching the
663        // function body instead of using a fragile char-window offset.
664        let source = include_str!("cgroup.rs");
665        let fn_start = source.find("pub fn cleanup").unwrap();
666        let after = &source[fn_start..];
667        let open = after.find('{').unwrap();
668        let mut depth = 0u32;
669        let mut fn_end = open;
670        for (i, ch) in after[open..].char_indices() {
671            match ch {
672                '{' => depth += 1,
673                '}' => {
674                    depth -= 1;
675                    if depth == 0 {
676                        fn_end = open + i + 1;
677                        break;
678                    }
679                }
680                _ => {}
681            }
682        }
683        let cleanup_body = &after[..fn_end];
684        let removed_pos = cleanup_body
685            .find("Removed")
686            .expect("must reference Removed state");
687        let remove_dir_pos = cleanup_body
688            .find("remove_dir")
689            .expect("must call remove_dir");
690        assert!(
691            removed_pos > remove_dir_pos,
692            "CgroupState::Removed must be set AFTER remove_dir succeeds, not before"
693        );
694    }
695
696    #[test]
697    fn test_parse_cgroup_events_populated() {
698        assert!(Cgroup::parse_cgroup_events_populated("populated 1\nfrozen 0\n").unwrap());
699        assert!(!Cgroup::parse_cgroup_events_populated("frozen 0\npopulated 0\n").unwrap());
700    }
701
702    #[test]
703    fn test_set_limits_source_enables_memory_oom_group() {
704        let source = include_str!("cgroup.rs");
705        let fn_start = source.find("pub fn set_limits").unwrap();
706        let after = &source[fn_start..];
707        let open = after.find('{').unwrap();
708        let mut depth = 0u32;
709        let mut fn_end = open;
710        for (i, ch) in after[open..].char_indices() {
711            match ch {
712                '{' => depth += 1,
713                '}' => {
714                    depth -= 1;
715                    if depth == 0 {
716                        fn_end = open + i + 1;
717                        break;
718                    }
719                }
720                _ => {}
721            }
722        }
723        let body = &after[..fn_end];
724        assert!(
725            body.contains("memory.oom.group"),
726            "set_limits must enable memory.oom.group when memory controls are configured"
727        );
728    }
729
730    #[test]
731    fn test_cleanup_source_kills_processes_before_remove_dir() {
732        let source = include_str!("cgroup.rs");
733        let fn_start = source.find("pub fn cleanup").unwrap();
734        let after = &source[fn_start..];
735        let open = after.find('{').unwrap();
736        let mut depth = 0u32;
737        let mut fn_end = open;
738        for (i, ch) in after[open..].char_indices() {
739            match ch {
740                '{' => depth += 1,
741                '}' => {
742                    depth -= 1;
743                    if depth == 0 {
744                        fn_end = open + i + 1;
745                        break;
746                    }
747                }
748                _ => {}
749            }
750        }
751        let body = &after[..fn_end];
752        let freeze_pos = body.find("set_frozen(true)").unwrap();
753        let kill_pos = body.find("kill_all_processes").unwrap();
754        let remove_dir_pos = body.find("remove_dir").unwrap();
755        assert!(
756            freeze_pos < kill_pos && kill_pos < remove_dir_pos,
757            "cleanup must freeze and kill the cgroup before attempting remove_dir"
758        );
759    }
760}