From mboxrd@z Thu Jan 1 00:00:00 1970 Received: from atuin.qyliss.net (localhost [IPv6:::1]) by atuin.qyliss.net (Postfix) with ESMTP id DEA2652FD; Fri, 21 Aug 2026 06:53:42 +0000 (UTC) Received: by atuin.qyliss.net (Postfix, from userid 993) id 2521E52E3; Fri, 21 Aug 2026 06:53:40 +0000 (UTC) X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on atuin.qyliss.net X-Spam-Level: X-Spam-Status: No, score=-0.1 required=3.0 tests=DKIM_SIGNED,DKIM_VALID, DKIM_VALID_AU,DMARC_PASS,FREEMAIL_FROM,RCVD_IN_DNSWL_NONE, SPF_HELO_NONE autolearn=unavailable autolearn_force=no version=4.0.1 Received: from mail-yw1-x1132.google.com (mail-yw1-x1132.google.com [IPv6:2607:f8b0:4864:20::1132]) by atuin.qyliss.net (Postfix) with ESMTPS id B04CC52B4 for ; Fri, 21 Aug 2026 06:53:36 +0000 (UTC) Received: by mail-yw1-x1132.google.com with SMTP id 00721157ae682-836cd7310f4so11672167b3.2 for ; Thu, 20 Aug 2026 23:53:36 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1787295215; x=1787900015; darn=spectrum-os.org; h=cc:to:in-reply-to:references:message-id:content-transfer-encoding :content-type:mime-version:subject:date:from:from:to:cc:subject:date :message-id:reply-to:content-type; bh=7FAho+6TM42fl9rwO+5GMpKKL7chQ+NE18Aed4bUz3Q=; b=AoHl+Y4K/xuZAvdfyfqvE1kAXdWy3HXrV6oLeaskmLK0ozfiyP5XaB+87Upfq/c0AN Qz2Y7oUNJlVWivIuE+1HPMy41MAAToyMP4/PTcxy2AYeT9beTe8b2obXycNgzzozQqUw KCctOqvechSKwdefgtXKnINDnF5M5lm2ZN+fv0NZgA28DaRIqVa+SH/P5F8q3bQ1wGUW 0K6e0veBc2r8ikfKEa7SrWT0OP2BLwRgjx/5cT4FtNnm0mFdHxWa4az9+DCvgnEWteq9 5iUsCXUQsfm06FKpbvZQ84p7Uf7NY0P9hg1gYhbB5g/7ixOUXzm0pgORGykzFroeNLGM dXHw== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1787295215; x=1787900015; h=cc:to:in-reply-to:references:message-id:content-transfer-encoding :content-type:mime-version:subject:date:from:x-gm-gg :x-gm-message-state:from:to:cc:subject:date:message-id:reply-to :content-type; bh=7FAho+6TM42fl9rwO+5GMpKKL7chQ+NE18Aed4bUz3Q=; b=CjlWsbk2uEM2FEeO4OadlsP9+OsrerBKGgRouhCOm83NXm3yK4v6aIVmDLUiS5dkYP IYsB+PRUT1KY3vVvliWFEVLOluZB1jW7vflREkYpdvG4B4XJP3rTASpJlk2u1wQEJWIk DxGRwuVMYURV6CxyGQTvnC6jsxHnYMM8iQStbF5CNVz1lZx/eQ31la8LVbz0dQDSlrQS Fi3V4xSnZD9LdKftj0wXMV5KODR17VWqDe7BqC8Oz0Z2v0KwmwC0qmT9ZuFKHpwixsQl IOq9ubfEbCddgbjB9w54uZHs7ajlA3DY2DBAY8EoB1fcImMQOJJl/1XfmvwK/fc799WG kgeA== X-Gm-Message-State: AFuF++ktsKWXTftpxtcmPJ9Pvb+0rEYLPpgeasfLD57J36S29p8eS/y8 XTQZgdQy/kJoPi32CVO+gLXFNNW2L8FY2FRCgx5l32+LY4YBOcDdiHt8tQGAzCpK X-Gm-Gg: AR+sD110wkM7qZdoDI5EYL7GOoPhBIqGCW2O0kjyNWxdox6eMDD55CckGr1Bq8X+Njg ZOGG5JPRREcjSEBdwtoh9/fQi98Ep3YzbQ76pqZKDaEvt4ITZ1qGFGhZ0+k7mVoYWNBHBO6PCk/ rQSosPbVz7L8QwN20YTjEl9WcutoSnSIiRSgLM29VDpr/HWM8FuAgjtk/S0VTagIHG08031XmYM hGytqB7JIxvb+cqR3ZLZ1lpxJBcZswTlF/9XMRf2J2l4/LZnkINIlozS5N2E1Qt8of/Knd4y8hr sJHyBAc1pP2h8k5x6/TwHEqPJj+BB2ESPryi2suM5k4V4e4TCdxyg/IHViNebtBco6H5mRZaFRp CXls8X1767C8taNyB+OdwlQKMl6aaTw6fKtKG7DkZmNbOk/b5Nk2IHhrI03T7nas+GCAwzxE7i9 OFT1ccFvw8+NO4WVAckuFfvFpZzt3vCvDjGokw4CC18VVLpauVGZ6WPvNXZxcsGvuOAjEcFDlag tVsc1Q= X-Received: by 2002:a05:690c:e197:10b0:81d:472c:17a8 with SMTP id 00721157ae682-849ea017381mr14852857b3.0.1787295214961; Thu, 20 Aug 2026 23:53:34 -0700 (PDT) Received: from localhost.localdomain ([185.98.168.14]) by smtp.gmail.com with UTF8SMTPSA id 00721157ae682-845184cbe7bsm35956467b3.35.2026.08.20.23.53.31 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Thu, 20 Aug 2026 23:53:32 -0700 (PDT) From: Demi Marie Obenour Date: Fri, 21 Aug 2026 02:51:45 -0400 Subject: [PATCH v7 02/19] tools: Add control group manager MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 7bit Message-Id: <20260821-cgroups-v7-2-7f1870dedefc@gmail.com> References: <20260821-cgroups-v7-0-7f1870dedefc@gmail.com> In-Reply-To: <20260821-cgroups-v7-0-7f1870dedefc@gmail.com> To: Spectrum OS Development X-Mailer: b4 0.15.2 X-Developer-Signature: v=1; a=ed25519-sha256; t=1787295104; l=25763; i=demiobenour@gmail.com; s=20250729; h=from:subject:message-id; bh=9bmmggHAukNh+YCbzJNxxBZemBarUiBP6tta9otPZTU=; b=lvlQaJf3gZJclC/+7mE0ULf4ga+JYCVEzV1rl2e1vF8qgL7CsIxmwAh3L+T+LSvQ5uyKpLXCw 1QMSNgE60DZBrxIqbOet1km3zLIGphG4KOwwX5hAis4fjetHoP7TUyv X-Developer-Key: i=demiobenour@gmail.com; a=ed25519; pk=X57Q4/YQDj9t4SBeKaDwvXYKB6quZJVx/DE2Ly2out0= Message-ID-Hash: 3ASC5AU2YDORPT2L4TZHWTNYCGEZEICM X-Message-ID-Hash: 3ASC5AU2YDORPT2L4TZHWTNYCGEZEICM X-MailFrom: demiobenour@gmail.com X-Mailman-Rule-Hits: member-moderation X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; header-match-devel.spectrum-os.org-0; header-match-devel.spectrum-os.org-1; header-match-devel.spectrum-os.org-2; header-match-devel.spectrum-os.org-3; header-match-devel.spectrum-os.org-4; emergency CC: Demi Marie Obenour , Alyssa Ross X-Mailman-Version: 3.3.10 Precedence: list List-Id: Patches and low-level development discussion Archived-At: List-Archive: List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: This program has two modes: 1. Create a control group if it doesn't exist, optionally wait for other programs in it to exit, and exec another program in it. 2. Purge a control group: kill all programs in it, then delete it. Locking is used to ensure that concurrent invocations are safe. Signed-off-by: Demi Marie Obenour --- .codespellrc | 2 +- host/rootfs/default.nix | 6 +- pkgs/default.nix | 1 + tools/cgroup-setup/Cargo.lock | 67 ++++++++ tools/cgroup-setup/Cargo.lock.license | 2 + tools/cgroup-setup/Cargo.toml | 10 ++ tools/cgroup-setup/default.nix | 22 +++ tools/cgroup-setup/src/cgroup.rs | 289 ++++++++++++++++++++++++++++++++++ tools/cgroup-setup/src/main.rs | 168 ++++++++++++++++++++ 9 files changed, 563 insertions(+), 4 deletions(-) diff --git a/.codespellrc b/.codespellrc index d8023afc64ec44e98a88c5e397c8d5c681dde063..ae20da8309530759ac729689825a4afc683afa09 100644 --- a/.codespellrc +++ b/.codespellrc @@ -2,4 +2,4 @@ # SPDX-License-Identifier: CC0-1.0 [codespell] -ignore-words-list = crate,passt,rouge,ser +ignore-words-list = crate,passt,rouge,ser,WRONLY diff --git a/host/rootfs/default.nix b/host/rootfs/default.nix index 6bfeefbe0a5f76c1538ccb40e5eb8f291f5d3592..ccf626e2ec0f4bf96573dc5edf058c9375bb65f6 100644 --- a/host/rootfs/default.nix +++ b/host/rootfs/default.nix @@ -8,7 +8,7 @@ import ../../lib/call-package.nix ( }: pkgsMusl.callPackage ( -{ spectrum-host-tools, spectrum-router +{ spectrum-host-tools, spectrum-router, spectrum-cgroup-setup , lib, stdenvNoCC, nixos, runCommand, writeClosure, erofs-utils, s6-rc , btrfs-progs, bubblewrap, busybox, cloud-hypervisor, cosmic-files , crosvm, cryptsetup, dejavu_fonts, dbus, execline, foot, fuse3 @@ -27,8 +27,8 @@ let packages = [ btrfs-progs bubblewrap cloud-hypervisor cosmic-files crosvm cryptsetup dbus execline fuse3 inotify-tools iproute2 jq kmod mdevd mount-flatpak s6 - s6-linux-init s6-rc shadow socat spectrum-host-tools spectrum-router - virtiofsd xdg-desktop-portal-spectrum-host + s6-linux-init s6-rc shadow socat spectrum-cgroup-setup spectrum-host-tools + spectrum-router virtiofsd xdg-desktop-portal-spectrum-host (foot.override { allowPgo = false; }) diff --git a/pkgs/default.nix b/pkgs/default.nix index 44f7b5ff78cb6b9e755292a6a417d0b627ed3fb0..0a13393164ad5d7f752e630763f3f97166479af5 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -51,6 +51,7 @@ let driverSupport = true; }; spectrum-router = self.callSpectrumPackage ../tools/router {}; + spectrum-cgroup-setup = self.callSpectrumPackage ../tools/cgroup-setup {}; xdg-desktop-portal-spectrum-host = self.callSpectrumPackage ../tools/xdg-desktop-portal-spectrum-host {}; diff --git a/tools/cgroup-setup/Cargo.lock b/tools/cgroup-setup/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..fe967b3aa02c296c87b6b36ac59253dbe0a32de9 --- /dev/null +++ b/tools/cgroup-setup/Cargo.lock @@ -0,0 +1,67 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cgroup-setup" +version = "0.0.0" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/tools/cgroup-setup/Cargo.lock.license b/tools/cgroup-setup/Cargo.lock.license new file mode 100644 index 0000000000000000000000000000000000000000..f80078163f4428881b86cb6fef0c90c0c1ebaa91 --- /dev/null +++ b/tools/cgroup-setup/Cargo.lock.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Demi Marie Obenour +SPDX-License-Identifier: CC0-1.0 diff --git a/tools/cgroup-setup/Cargo.toml b/tools/cgroup-setup/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..e44ef64119aefc23a3119fe0ce01566f5d7ec98c --- /dev/null +++ b/tools/cgroup-setup/Cargo.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 Demi Marie Obenour +# SPDX-License-Identifier: CC0-1.0 + +[package] +name = "cgroup-setup" +edition = "2024" + +[dependencies] +libc = "0.2.177" +rustix = { version = "1.1.2", features = ["fs"] } diff --git a/tools/cgroup-setup/default.nix b/tools/cgroup-setup/default.nix new file mode 100644 index 0000000000000000000000000000000000000000..fe3a8bcd4e6118f3cbf49c2fc0e3cda01570c484 --- /dev/null +++ b/tools/cgroup-setup/default.nix @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2024 Alyssa Ross +# SPDX-FileCopyrightText: 2025 Yureka Lilian +# SPDX-License-Identifier: MIT + +import ../../lib/call-package.nix ( +{ src, lib, rustPlatform }: + +rustPlatform.buildRustPackage { + name = "spectrum-cgroup-setup"; + + src = lib.fileset.toSource { + root = ../..; + fileset = lib.fileset.intersection src ./.; + }; + sourceRoot = "source/tools/cgroup-setup"; + + cargoLock.lockFile = ./Cargo.lock; + + postInstall = '' + ln -s -- cgroup-setup "$out/bin/cgroup-purge" + ''; +}) (_: {}) diff --git a/tools/cgroup-setup/src/cgroup.rs b/tools/cgroup-setup/src/cgroup.rs new file mode 100644 index 0000000000000000000000000000000000000000..463d3fc87ee34ccfbf399a337e2f49d9031728ee --- /dev/null +++ b/tools/cgroup-setup/src/cgroup.rs @@ -0,0 +1,289 @@ +// SPDX-FileCopyrightText: 2026 Demi Marie Obenour +// SPDX-License-Identifier: EUPL-1.2+ + +use std::ffi::OsStr; +use std::fs::File; +use std::io::{Read as _, Seek as _, Write as _}; +use std::os::unix::prelude::*; + +use std::path::{Component, Path, PathBuf}; + +use rustix::fs::{AtFlags, CWD, Dir, FlockOperation}; +use rustix::path; +use rustix::{ + fs::{Mode, OFlags, ResolveFlags}, + io::Errno, +}; + +pub enum OpenFlags { + Read, + Write, + Directory, +} + +#[derive(Debug)] +pub(crate) struct Cgroup { + fd: Vec, +} + +impl AsFd for Cgroup { + fn as_fd(&self) -> BorrowedFd<'_> { + self.fd.last().unwrap().as_fd() + } +} + +fn assert_single_component(component: &Path) { + match component.as_os_str().as_bytes() { + b"" | b"." | b".." => panic!("bad component"), + c if c.contains(&b'\0') => panic!("NUL in component"), + c if c.contains(&b'/') => panic!("/ in component"), + _ => {} + } +} + +// Wrapper around openat2() with better defaults. +pub fn openat2_simple( + fd: impl AsFd, + path: impl path::Arg, + flags: OpenFlags, +) -> Result { + rustix::fs::openat2( + fd.as_fd(), + path, + OFlags::CLOEXEC + | match flags { + OpenFlags::Read => OFlags::RDONLY | OFlags::NOCTTY, + OpenFlags::Write => OFlags::WRONLY | OFlags::NOCTTY, + OpenFlags::Directory => OFlags::RDONLY | OFlags::DIRECTORY, + }, + Mode::empty(), + ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_XDEV, + ) +} + +pub const DEFAULT_LEAF: &str = "$inner.service"; + +pub fn check_path(path: &Path) -> Result<(), String> { + let bytes = path.as_os_str().as_bytes(); + // Path::components() skips ., so use string manipulation instead. + for component in bytes[path.is_absolute() as usize..].split(|&b| b == b'/') { + if matches!(component, b"" | b"." | b"..") { + return Err(format!("cgroup path {path:?} isn't canonical")); + } + } + Ok(()) +} + +// Remove all subdirectories of the given directory recursively, but not the +// directory itself. The directory file descriptor is closed. +// +// This isn't the most efficient possible algorithm, but simplicity is more +// important than performance in this case. Also, it keeps open more file +// descriptors than strictly necessary, but Spectrum runs with a very high limit +// for the number of open file descriptors, and it uses shallow control group +// hierarchies. +// +// This uses a recursive algorithm, but so does std::fs::remove_dir_all(). Trying +// to be more robust than the standard library is not worthwhile. In particular, +// the standard library function must be safe on systems where untrusted users (or +// even network endpoints!) can create deeply nested directory trees, whereas in +// Spectrum cgroups are only writeable by root. +fn remove_recursively(mut dirfd: Dir, remaining_depth: usize) -> Result<(), Errno> { + if remaining_depth < 1 { + panic!("control groups too deeply nested"); + } + while let Some(element) = dirfd.next() { + let parent_fd = dirfd.fd().unwrap(); + let element = element.expect("Iterating through a cgroup directory failed?"); + let path = element.file_name(); + if element.file_type() != rustix::fs::FileType::Directory || path == c"." || path == c".." { + continue; + } + let fd = openat2_simple(parent_fd, path, OpenFlags::Directory)?; + remove_recursively(Dir::new(fd).unwrap(), remaining_depth - 1)?; + rustix::fs::unlinkat(parent_fd, path, AtFlags::REMOVEDIR)?; + } + Ok(()) +} + +fn assert_simple_path(current_cgroup: &Path) { + let current_cgroup = current_cgroup.as_os_str().as_bytes(); + if !matches!(current_cgroup, b"" | b".") { + for component in current_cgroup.split(|&b| b == b'/') { + assert_single_component(Path::new(OsStr::from_bytes(component))); + } + } +} + +// Convert the cgroup path to one relative to /sys/fs/cgroup. +// +// If the path starts with /, the leading / is removed and the result is returned +// without further processing. Otherwise, the current cgroup is read from +// /proc/thread-self/cgroup. If its last component is $inner.service, that is +// removed. Finally, the current cgroup is prepended to the provided cgroup path, +// with a single / as separator. The result of this operation is returned. +fn prepend_current_cgroup_if_needed(path: &Path) -> Result { + if let Ok(suffix) = path.strip_prefix("/") { + return Ok(suffix.to_owned()); + } + assert_simple_path(path); + // /proc/thread-self is the same as /proc/self, except for the current thread + // instead of the initial thread. In this case, the two are identical, but + // using /proc/thread-self is better practice as it is correct in more cases. + // Reading /proc/thread-self/cgroup should never fail unless the system is + // seriously broken. + let current_cgroup = + std::fs::read("/proc/thread-self/cgroup").expect("cannot read /proc/thread-self/cgroup"); + // Using this on a system without cgroups v2 mounted is user error + // and not supported. + let current_cgroup = current_cgroup + .strip_prefix(b"0::/") + .and_then(|e| e.strip_suffix(b"\n")) + .ok_or_else(|| { + "/proc/thread-self/cgroup doesn't start with 0::/ or doesn't end with a newline.\n\ + Either cgroups aren't in use at all, or you are using cgroups v1." + .to_owned() + })?; + let mut current_cgroup = PathBuf::from(OsStr::from_bytes(current_cgroup)); + assert_simple_path(¤t_cgroup); + // Strip the implied $inner.service suffix. + // This is used to satisfy the "no internal processes" rule. + if current_cgroup.ends_with(Path::new(DEFAULT_LEAF)) { + assert!(current_cgroup.pop()); + } + // "." refers to the current cgroup. + if path != Path::new(".") { + current_cgroup.push(path); + } + Ok(current_cgroup) +} + +pub(crate) fn write_value(fd: &dyn AsFd, name: &Path, value: &[u8]) -> Result<(), String> { + let fd = openat2_simple(fd, name, OpenFlags::Write) + .map_err(|e| format!("Cannot open {name:?}: {e}"))?; + File::from(fd).write_all(value).map_err(|e| { + format!( + "Cannot write {:?} to {name:?}: {e}", + OsStr::from_bytes(value) + ) + }) +} + +impl Cgroup { + pub fn new(path: &Path) -> Result { + let cgroup_root = rustix::fs::openat2( + CWD, + Path::new("/sys/fs/cgroup"), + OFlags::CLOEXEC | OFlags::DIRECTORY | OFlags::RDONLY, + Mode::empty(), + ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS, + ) + .map_err(|e| format!("Cannot open /sys/fs/cgroup: {e}"))?; + // It's simpler to always have the root cgroup at the bottom of the stack, + // even though no lock needs to be taken on it. Otherwise, one would need + // to special-case the cgroup root. One could remove the first element if + // there is more than one element in the vector, but that's not worth it. + // cgroup-setup doesn't operate in an environment where FDs are a limited + // resource. + let mut cgroup = Self { + fd: vec![cgroup_root], + }; + + let path = prepend_current_cgroup_if_needed(path)?; + for component in path.components() { + let Component::Normal(component) = component else { + unreachable!() + }; + let sub_fd = openat2_simple(&cgroup, component, OpenFlags::Directory) + .map_err(|e| format!("Cannot open sub-cgroup {component:?}: {e}"))?; + // Take a shared lock on the cgroup. + rustix::fs::flock(&sub_fd, FlockOperation::LockShared) + .map_err(|e| format!("Cannot lock sub-cgroup {component:?}: {e}"))?; + cgroup.fd.push(sub_fd); + } + Ok(cgroup) + } + + pub fn wait_for_empty(fd: &dyn AsFd) -> std::io::Result<()> { + let wait_file = openat2_simple(fd, c"cgroup.events", OpenFlags::Read)?; + let mut wait_fd = File::from(wait_file); + let mut v = vec![]; + loop { + v.clear(); + wait_fd + .seek(std::io::SeekFrom::Start(0)) + .expect("Seek on control group file should succeed"); + wait_fd + .read_to_end(&mut v) + .expect("reading from control group should work"); + // Check that the cgroup isn't already empty. If it was, + // the kernel would not send an event and poll() would wait + // forever. + if v.split(|&c| c == b'\n').any(|line| line == b"populated 0") { + break; + } + let mut fds = libc::pollfd { + fd: wait_fd.as_raw_fd(), + events: libc::POLLPRI | libc::POLLERR, + revents: 0, + }; + // SAFETY: FFI call, valid arguments, fds contains 1 element + if unsafe { libc::poll(&raw mut fds, 1, -1) } != 1 { + panic!("poll failed"); + } + } + Ok(()) + } + + pub fn purge_child(&mut self, path: &Path) -> Result<(), String> { + assert_single_component(path); + // See if we can just delete the child directly. + match rustix::fs::unlinkat(&self, path, AtFlags::REMOVEDIR) { + // If the cgroup was successfully deleted, or if it + // has already been deleted, we are done. + Ok(()) | Err(Errno::NOENT) => return Ok(()), + // If this cgroup is in use, keep going. + Err(Errno::BUSY) => {} + Err(e) => return Err(format!("Cannot purge {path:?}: {e}")), + } + + let sub_fd = match openat2_simple(&self, path, OpenFlags::Directory) { + Ok(sub_fd) => sub_fd, + Err(Errno::NOENT) => return Ok(()), + Err(e) => { + return Err(format!("Cannot open sub-cgroup {path:?}: {e}",)); + } + }; + + // Take an exclusive lock on the cgroup that is about to be removed. This + // avoids concurrent executions of this program operating on deleted + // sub-cgroups. Dir::new() doesn't expose a reference to its internal FD + // so it must be delayed until later. + rustix::fs::flock(&sub_fd, FlockOperation::LockExclusive) + .map_err(|e| format!("Cannot lock sub-cgroup: {e}"))?; + + // Kill all processes in the child cgroup. + write_value(&sub_fd, Path::new("cgroup.kill"), b"1")?; + + // Wait for the child cgroup to become empty. + Self::wait_for_empty(&sub_fd) + .map_err(|e| format!("Cannot wait for cgroup to become empty: {e}")) + .inspect_err(|_| { + self.fd.pop().unwrap(); + })?; + + // Remove the child cgroup and its contents recursively. + remove_recursively(Dir::new(sub_fd).unwrap(), 1000) + .map_err(|e| format!("Cannot remove: {e}"))?; + + // Delete the cgroup. If it's been re-created in the meantime and is + // currently in use, this is not an error. Another process deleting the + // cgroup is also not an error. Both of these can happen because of the + // time period between remove_child_directories() closing the file + // descriptor (releasing its lock) and the above call to flock(). + match rustix::fs::unlinkat(&self, path, AtFlags::REMOVEDIR) { + Ok(()) | Err(Errno::BUSY) | Err(Errno::NOENT) => Ok(()), + Err(e) => Err(format!("Cannot delete: {e}")), + } + } +} diff --git a/tools/cgroup-setup/src/main.rs b/tools/cgroup-setup/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..c757a4ad37c812ef5ce5249dc1bc3104ec246eef --- /dev/null +++ b/tools/cgroup-setup/src/main.rs @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: 2026 Demi Marie Obenour +// SPDX-License-Identifier: EUPL-1.2+ + +mod cgroup; + +use cgroup::{Cgroup, OpenFlags, openat2_simple, write_value}; +use rustix::{ + fs::{FlockOperation, Mode, XattrFlags}, + io::Errno, +}; +use std::{ + env::ArgsOs, + fs::File, + io::Read as _, + os::unix::prelude::*, + path::{Path, PathBuf}, +}; + +fn enable_subtree_control(fd: &dyn AsFd) -> Result<(), String> { + rustix::fs::fsetxattr(fd, c"user.delegate", b"1", XattrFlags::empty()) + .map_err(|e| format!("Cannot enable cgroup delegation: {e}"))?; + let mut buf = Vec::new(); + File::from( + openat2_simple(fd, c"cgroup.controllers", OpenFlags::Read) + .map_err(|e| format!("Cannot open cgroup.controllers: {e}"))?, + ) + .read_to_end(&mut buf) + .map_err(|e| format!("Cannot read cgroup.controllers: {e}"))?; + let mut subtree = vec![]; + if buf.is_empty() { + return Ok(()); + } + for controller in buf.split(|&b| b == b' ') { + if !subtree.is_empty() { + subtree.push(b' '); + } + subtree.push(b'+'); + subtree.extend_from_slice(controller); + } + if !subtree.is_empty() { + write_value(&fd, Path::new("cgroup.subtree_control"), &subtree)?; + } + Ok(()) +} + +fn spawn_in_cgroup( + mut args: std::iter::Peekable, + cgroup: Option<&dyn AsFd>, +) -> Result<(), String> { + let Some(program_name) = args.next() else { + return Ok(()); + }; + if let Some(cgroup) = cgroup { + let pid = std::process::id().to_string(); + write_value( + cgroup, + Path::new("$inner.service/cgroup.procs"), + pid.as_bytes(), + ) + .map_err(|e| format!("Cannot move process to child cgroup: {e}"))?; + } + let e = std::process::Command::new(&program_name).args(args).exec(); + Err(format!("Cannot spawn child {program_name:?}: {e}")) +} + +// Check that the path is canonical, +// then split it into basename and filename. +fn split_path(path: &Path) -> Result<(&Path, &Path), String> { + cgroup::check_path(path)?; + Ok((path.parent().unwrap(), Path::new(path.file_name().unwrap()))) +} + +fn cgroup_setup(args: ArgsOs) -> Result<(), String> { + let mut wait = true; + let mut args = args.peekable(); + while let Some(arg) = args.peek() { + if !arg.as_bytes().starts_with(b"-") { + break; + } + let arg = args.next().unwrap(); + let Some(option) = arg.as_bytes().strip_prefix(b"--") else { + return Err("takes no short options".to_owned()); + }; + match option { + b"" => break, + b"no-wait" => wait = false, + _ => return Err(format!("unknown long option {arg:?}")), + } + } + let Some(cgroup_path) = args.next().map(PathBuf::from) else { + return Err("have no positional arguments, expected at least 1".to_owned()); + }; + + let (parent_cgroup_path, child_cgroup_path) = split_path(&cgroup_path)?; + let cgroup = Cgroup::new(parent_cgroup_path)?; + match rustix::fs::mkdirat(&cgroup, child_cgroup_path, Mode::from_raw_mode(0o755)) { + Ok(()) | Err(Errno::EXIST) => {} + Err(e) => { + return Err(format!( + "Cannot create child cgroup {child_cgroup_path:?}: {e}" + )); + } + } + + let child = openat2_simple(&cgroup, child_cgroup_path, OpenFlags::Directory) + .map_err(|e| format!("Cannot open child cgroup: {e}"))?; + + // While waiting, hold an exclusive lock on the child. + // This avoids two processes both waiting for the same cgroup to become + // empty, then spawning processes in the same cgroup. + rustix::fs::flock(&child, FlockOperation::LockExclusive) + .map_err(|e| format!("Cannot take an exclusive lock on child cgroup: {e}"))?; + if wait { + Cgroup::wait_for_empty(&child) + .map_err(|e| format!("Cannot wait for {parent_cgroup_path:?} to be empty: {e}"))?; + } + + // Spectrum's programs (such as this one) expect cgroup.subtree_control + // to be set by the program that created the cgroup. systemd-aware + // programs, like systemd-udevd, expect user.delegate=1 to be set. + enable_subtree_control(&child)?; + + // If the child process will need to manage cgroups itself, it will need + // to set up a sub-cgroup due to the "no internal processes" rule. It's + // simplest to just do it automatically. If the cgroup already exists, + // that isn't an error. + match rustix::fs::mkdirat(&child, cgroup::DEFAULT_LEAF, Mode::from_raw_mode(0o755)) { + Ok(()) | Err(Errno::EXIST) => {} + Err(e) => return Err(format!("Cannot create $inner.service cgroup: {e}")), + } + + spawn_in_cgroup(args, Some(&child)) +} + +fn cgroup_purge(mut args: ArgsOs) -> Result<(), String> { + if args.len() != 1 { + return Err("usage: cgroup-purge CGROUP_TO_PURGE".to_owned()); + } + let arg = args.next().unwrap(); + let (parent, child) = split_path(Path::new(&arg))?; + Cgroup::new(parent)?.purge_child(child) +} + +fn run(prog_name: &Path, args: ArgsOs) -> Result<(), String> { + match prog_name.file_name().map(|f| f.as_bytes()) { + Some(b"cgroup-setup") => cgroup_setup(args), + Some(b"cgroup-purge") => cgroup_purge(args), + _ => Err(format!( + "must be invoked as \"cgroup-setup\" or \ + \"cgroup-purge\", got {prog_name:?}", + )), + } +} + +fn main() { + let mut args = std::env::args_os(); + let Some(prog_name) = args.next() else { + eprintln!("No command line arguments (argv[0] is NULL)"); + std::process::exit(1); + }; + match run(Path::new(&prog_name), args) { + Ok(()) => {} + Err(e) => { + eprintln!("{prog_name:?}: {}", e); + std::process::exit(1); + } + } +} -- 2.55.0