From 591273f65ca1a9ae24aefb8dbbe0fc74ee06f547 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:31:15 +0000 Subject: [PATCH 01/23] docs: lean audit with baseline metrics from the last ISO build Sizes come from CI run 48 (4ca2f45): ISO, rootfs, installed size and the largest packages of both editions. The bloat table lists what each item costs, why it is bloat and what is done about it; product decisions are listed separately and not acted on. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- docs/lean-audit.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/lean-audit.md diff --git a/docs/lean-audit.md b/docs/lean-audit.md new file mode 100644 index 0000000..1fddc33 --- /dev/null +++ b/docs/lean-audit.md @@ -0,0 +1,90 @@ +# Lean audit + +What the images weigh, what is bloat and what was done about it. The audit +(this file's first commit) comes before any removal, so each change can be +checked against it. + +* Init system: **OpenRC** is the default and the only one the Lite edition + builds; the full edition also builds runit and dinit. Every new service + file ships for all three, nothing assumes systemd. +* Repositories: Artix (`system`, `world`, `galaxy`, `lib32`) and + Chaotic-AUR, which exists only for `brave-bin`. The Arch repositories are + not enabled. +* Sizes below are *uncompressed installed sizes* from the ISO workflow's + "What takes the space" report (`build-in-container.sh`), unless a row says + otherwise. + +## Before + +Measured by CI run 48 of 19 October 2026 (commit `4ca2f45`, OpenRC, +linux-zen), the last ISO build before this work. + +| Metric | Lite | Full | +|---|---|---| +| ISO | 774,014,976 B (738 MiB) | 3,030,378,496 B (2.82 GiB) | +| `LiveOS/rootfs.img` | 637.2 MiB | 2,681.8 MiB | +| initramfs | 49.7 MiB | 156.7 MiB | +| Installed system, uncompressed | 1,790.1 MiB | 7,139.8 MiB | +| Package list entries (OpenRC) | 48 | 164 (159 unique: 5 listed twice) | +| Services started in the live session | 1 | 8 | +| Services enabled by Setup on an installed system | 14 | 14 | +| Idle RAM | not measured | 792 MB used, live session, 4 GB VM, software GL (docs/desktop.md, 15 Oct) | +| Boot time in QEMU | not measured | not measured | +| Rust code | 24,849 lines, 7 crates | | + +Boot time and idle RAM could not be measured for this work: the sandbox it +ran in has no route to the Artix mirrors, no QEMU and no loop devices, so an +ISO cannot be built or booted there (see "Verification limits" in the final +section). + +## Bloat table + +| Item | Size / cost | Why it is bloat | Action | +|---|---|---|---| +| `linux-zen-headers` (`linux-headers` in the list) | 325.1 MiB, on both editions (Lite's Setup downloads it too) | Only needed to build `nvidia-open-dkms` on GTX 16 / RTX GPUs. Setup already downloads that driver in a step that needs a network. | **remove** from the image; Setup adds the running kernel's headers to the NVIDIA step | +| `spectacle` (and `opencv`, which only it needs) | `opencv` 112.8 MiB, plus the libraries only opencv uses | Second screenshot tool: the desktop has its own (Print, Super+Shift+S: grim + slurp). Spectacle would only serve the Plasma fallback session. | **remove** | +| `xorg-server`, `xf86-input-*`, `xf86-video-*` | tens of MiB | Legacy X server and its drivers. The sessions are Wayland (labwc, Plasma Wayland); applications use XWayland. The Lite image already ships only `xorg-xwayland`. | **replace** with `xorg-xwayland` only, as Lite does | +| `inxi` | ~1 MiB (Perl stays: OpenRC needs it) | Third system-information tool beside fastfetch and KInfoCenter | **remove** | +| `cupsd` started at boot | an idle daemon on every machine; no socket activation without systemd | Few gaming PCs print | **disable by default** (live session and Setup); packages stay, `seg svc enable cupsd` turns it on | +| 5 packages listed twice (`xorg-server`, `xorg-xwayland`, `xdg-user-dirs`, `xdg-utils`, `ttf-liberation`) | none | Noise | **remove** the repeats | +| `kitty` | 65.6 MiB, needs Python (73.9 MiB, shared). 185 MB + 31 MB of RAM idle in the 15 Oct VM measurement, the largest process | Lite uses `foot`, which draws on the CPU and is tiny | **keep, maintainers decide**: the README presents kitty as the terminal | +| Plasma fallback session (`plasma-workspace` 57.8, `plasma-desktop` 42.0, `breeze` 38.8 MiB, KWin, PowerDevil, KScreen, ...) | roughly 250 MiB | Second desktop: second network applet (`plasma-nm` / `network-manager-applet`), volume (`plasma-pa` / `pavucontrol`), polkit agent, portal backends | **keep, maintainers decide**: the README advertises it as the fallback | +| `discover` -> `signon-ui` -> `qt6-webengine` | 282.9 MiB | A browser engine for the Store window | **keep, maintainers decide** (the Store icon is part of the Windows-like pitch) | +| `vlc` -> Qt 5 (`qt5-base` 64.0 MiB, declarative, svg, x11extras) | about 100 MiB | A second Qt generation for one application | **keep, maintainers decide** (a Qt 6 player would remove it) | +| `mangohud` -> `python-numpy` 49.6 MiB, matplotlib | about 100 MiB | Plotting libraries for a log tool | **keep**: the overlay is a headline gaming feature | +| `vim-runtime` 38.8 MiB | 38.8 MiB | Two command-line editors (`nano`, `vi`/vim) | **keep**: `crontab -e` and `visudo` fall back to `vi` | +| `samba` | 64.3 MiB | Needed by `kdenetwork-filesharing` | **keep**: sharing folders with Windows PCs | +| `wine` 584.9, `brave-bin` 471.6, `lutris` -> `webkit2gtk-4.1` 138.3 | | The product | **keep** | +| `linux-firmware-intel` 138.0, `-nvidia` 103.5, `sof-firmware` 55.1 | | The full edition installs offline on any common hardware | **keep** | +| `llvm-libs` 166.6, `lib32-llvm-libs` 187.8 | | Mesa and Steam need them | **keep** | +| `perl` 55.0 | | `openrc` depends on it | **keep** (protected) | +| `syslog-ng` | 28 MB RSS idle (15 Oct measurement) | Heavier than needed, but logging is wanted | **keep**; `metalog` is a candidate, needs a boot test | +| `bluetoothd` | ~2 MB idle | Controllers and headsets | **keep** | +| `ntpd`, `cronie`, `acpid`, `power-profiles-daemon` | small | Time, scheduled jobs (weekly fstrim uses cron), power events and profiles | **keep** | +| `etc/sddm.conf.d` file and SDDM migration code | 2 lines | SDDM is gone from the image; `seglinux-upgrade` still migrates older systems | **keep** until those systems are gone | +| `s6` entries in `common.yaml` | 12 lines | The build scripts reject s6 | **keep**: the same layout as Artix's own profile | + +Not bloat, checked: one network stack (NetworkManager with `wpa_supplicant`), +one audio stack (PipeWire), one browser, one file manager, one media player, +one terminal per edition. No `-git` packages, and the only package of +AUR origin (`brave-bin`, with `chaotic-keyring` and `chaotic-mirrorlist`) is +a deliberate product choice. No systemd units or `systemctl` calls anywhere +in the repository, and no package with the wrong init suffix. + +Protected, never touched: `base`, the kernel, `openrc` and the `-openrc` / +`-runit` / `-dinit` service packages, `elogind`, `artix-keyring` (through +`base`), `pacman`, `sudo`, NetworkManager, the firmware packages. + +## Decisions for the maintainers + +These are product choices, so they were measured and left alone: + +1. **kitty -> foot** for the full edition (Lite already uses foot): about + 65 MiB on disk and, by the 15 October measurement, about 200 MB less RAM + in a VM with software OpenGL. +2. **Drop the Plasma fallback session**: roughly 250 MiB and a second set of + desktop services. +3. **Drop Discover**, or the Store's account integration: 283 MiB of + `qt6-webengine`. +4. **A Qt 6 media player instead of VLC**: removes Qt 5, about 100 MiB. +5. **Brave from Chaotic-AUR**: the only AUR-origin package of the image. From 866fb9f67add5fb47afb83096b7e174ec7bddc28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:34:20 +0000 Subject: [PATCH 02/23] perf: leave kernel headers, spectacle, the X server and inxi off the image From the 19 October build report (installed size): linux-zen-headers 325 MiB, opencv 113 MiB (only spectacle needs it), plus the legacy xorg-server and xf86 driver stack and a third system-information tool. - Setup downloads the running kernel's headers with the NVIDIA DKMS driver, the only thing that builds modules. The kernel package comes from the pkgbase file of the running kernel. - The artools library patch that reads the kernel version from /usr/lib/modules now applies to every kernel: the stock one no longer has headers to read it from. - The desktop has its own screenshots, so spectacle goes; both sessions are Wayland, so the X stack becomes xorg-xwayland, as in Lite. - Five packages listed twice are listed once. - cupsd is no longer enabled by default (live session and Setup); the packages stay. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- crates/seglinux-installer/src/model.rs | 1 + crates/seglinux-setup/src/answer.rs | 3 +- crates/seglinux-setup/src/facts.rs | 30 +++++++++++++++++++ crates/seglinux-setup/src/plan.rs | 41 +++++++++++++++++++------- crates/seglinux-setup/src/tui.rs | 1 + docs/building.md | 11 +++---- docs/windows-to-seglinux.md | 2 +- iso/iso-profiles/common/common.yaml | 27 +++++------------ iso/iso-profiles/seglinux/profile.yaml | 7 ----- iso/scripts/build-in-container.sh | 12 ++++---- 10 files changed, 85 insertions(+), 50 deletions(-) diff --git a/crates/seglinux-installer/src/model.rs b/crates/seglinux-installer/src/model.rs index 327bbe5..283f8cd 100644 --- a/crates/seglinux-installer/src/model.rs +++ b/crates/seglinux-installer/src/model.rs @@ -200,6 +200,7 @@ pub fn demo_facts() -> Facts { network: true, memory: 8 * GIB, gpus: vec![], + kernel: Some("linux-zen".into()), timezones: Some(set(&[ "UTC", "Africa/Tunis", diff --git a/crates/seglinux-setup/src/answer.rs b/crates/seglinux-setup/src/answer.rs index 7c9621c..bcf3541 100644 --- a/crates/seglinux-setup/src/answer.rs +++ b/crates/seglinux-setup/src/answer.rs @@ -323,6 +323,8 @@ pub enum CommandPass { } /// Services enabled on every installation (missing ones are skipped). +/// `cupsd` is installed but not started: few gaming PCs print, and OpenRC has +/// no socket activation to start it on demand (`seg svc enable cupsd`). pub const DEFAULT_SERVICES: &[&str] = &[ "dbus", "elogind", @@ -332,7 +334,6 @@ pub const DEFAULT_SERVICES: &[&str] = &[ "NetworkManager", "greetd", "bluetoothd", - "cupsd", "cronie", "syslog-ng", "ntpd", diff --git a/crates/seglinux-setup/src/facts.rs b/crates/seglinux-setup/src/facts.rs index 06239c6..a89e925 100644 --- a/crates/seglinux-setup/src/facts.rs +++ b/crates/seglinux-setup/src/facts.rs @@ -109,6 +109,9 @@ pub struct Facts { pub network: bool, pub memory: u64, pub gpus: Vec, + /// Package name of the running kernel (`linux-zen`): the new system gets + /// the same one, and its headers when a driver has to be built. + pub kernel: Option, /// Known values for validation and pick lists; `None` = not checked. pub timezones: Option>, pub locales: Option>, @@ -140,6 +143,7 @@ impl Facts { network: has_default_route(), memory: memory_bytes().unwrap_or(0), gpus: list_gpus(Path::new("/sys/bus/pci/devices")), + kernel: running_kernel(), timezones: Some(list_timezones(Path::new("/usr/share/zoneinfo"))) .filter(|s| !s.is_empty()), locales: fs::read_to_string("/usr/share/i18n/SUPPORTED") @@ -291,6 +295,24 @@ pub fn parse_default_route(route: &str, ipv6_route: &str) -> bool { v4 || v6 } +/// The package that owns the running kernel, read from the `pkgbase` file +/// every Arch and Artix kernel package puts in its module directory. +pub fn running_kernel() -> Option { + let release = fs::read_to_string("/proc/sys/kernel/osrelease").ok()?; + let pkgbase = + fs::read_to_string(format!("/usr/lib/modules/{}/pkgbase", release.trim())).ok()?; + parse_pkgbase(&pkgbase) +} + +pub fn parse_pkgbase(text: &str) -> Option { + let name = text.lines().next()?.trim(); + let valid = !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.+".contains(c)); + valid.then(|| name.to_string()) +} + pub fn memory_bytes() -> Option { let text = fs::read_to_string("/proc/meminfo").ok()?; parse_meminfo_total(&text) @@ -487,6 +509,14 @@ mod tests { assert_eq!(parse_meminfo_total("nope"), None); } + #[test] + fn kernel_package_from_pkgbase() { + assert_eq!(parse_pkgbase("linux-zen\n"), Some("linux-zen".into())); + assert_eq!(parse_pkgbase("linux-lts"), Some("linux-lts".into())); + assert_eq!(parse_pkgbase(""), None); + assert_eq!(parse_pkgbase("linux zen; rm -rf /"), None); + } + #[test] fn locales_and_layouts() { let locales = diff --git a/crates/seglinux-setup/src/plan.rs b/crates/seglinux-setup/src/plan.rs index 81a55e7..caea182 100644 --- a/crates/seglinux-setup/src/plan.rs +++ b/crates/seglinux-setup/src/plan.rs @@ -817,15 +817,19 @@ pub fn build( description: format!( "Installing the NVIDIA driver for {gpu} (needs a network connection)" ), - actions: vec![Action::chroot_optional(Cmd::new("pacman").args([ - "-Syu", - "--noconfirm", - "--needed", - "nvidia-open-dkms", - "nvidia-utils", - "lib32-nvidia-utils", - "nvidia-settings", - ]))], + // The image carries no kernel headers (325 MiB nobody else + // needs); DKMS builds the driver against the ones fetched here. + actions: vec![Action::chroot_optional( + Cmd::new("pacman") + .args(["-Syu", "--noconfirm", "--needed"]) + .arg(kernel_headers(facts.kernel.as_deref())) + .args([ + "nvidia-open-dkms", + "nvidia-utils", + "lib32-nvidia-utils", + "nvidia-settings", + ]), + )], }); } sp.push(Step { @@ -1082,6 +1086,12 @@ fn run_command_step(c: &crate::answer::RunCommand) -> Step { } } +/// The headers package of the kernel the new system runs. The stock +/// kernel's is the fallback when the running one is unknown. +fn kernel_headers(kernel: Option<&str>) -> String { + format!("{}-headers", kernel.unwrap_or("linux")) +} + /// `UTF-8`/`utf8` -> `UTF-8` for locale.gen. fn charset_name(charset: &str) -> String { match charset.to_ascii_lowercase().replace('-', "").as_str() { @@ -1137,6 +1147,7 @@ mod tests { network: true, memory: 4 * GIB, gpus: vec![], + kernel: Some("linux-zen".into()), timezones: None, locales: None, keyboards: None, @@ -1288,10 +1299,20 @@ mod tests { }]; let text = build_plan(&answer(""), &f).unwrap().to_string(); assert!(text.contains("Installing the NVIDIA driver for NVIDIA 10de:2684")); - assert!(text.contains("pacman -Syu --noconfirm --needed nvidia-open-dkms nvidia-utils lib32-nvidia-utils nvidia-settings # may fail")); + assert!(text.contains("pacman -Syu --noconfirm --needed linux-zen-headers nvidia-open-dkms nvidia-utils lib32-nvidia-utils nvidia-settings # may fail")); // The driver is installed before the initramfs is built. assert!(text.find("nvidia-open-dkms").unwrap() < text.find("mkinitcpio -P").unwrap()); + // The headers match the kernel that is running; the stock kernel's + // are the fallback when it cannot be told. + f.kernel = Some("linux-lts".into()); + let text = build_plan(&answer(""), &f).unwrap().to_string(); + assert!(text.contains("--needed linux-lts-headers nvidia-open-dkms")); + f.kernel = None; + let text = build_plan(&answer(""), &f).unwrap().to_string(); + assert!(text.contains("--needed linux-headers nvidia-open-dkms")); + f.kernel = Some("linux-zen".into()); + let a = answer("[drivers]\nnvidia = \"never\"\n"); assert!( !build_plan(&a, &f) diff --git a/crates/seglinux-setup/src/tui.rs b/crates/seglinux-setup/src/tui.rs index 10fda2a..e14be2a 100644 --- a/crates/seglinux-setup/src/tui.rs +++ b/crates/seglinux-setup/src/tui.rs @@ -818,6 +818,7 @@ mod tests { network: true, memory: 8 * GIB, gpus: vec![], + kernel: Some("linux-zen".into()), timezones: Some( ["UTC", "Europe/Paris", "Europe/Berlin"] .iter() diff --git a/docs/building.md b/docs/building.md index a7f0716..dc70747 100644 --- a/docs/building.md +++ b/docs/building.md @@ -22,12 +22,13 @@ The ISO and its checksum are written to `iso/out/`, for example `common.yaml` names the stock `linux` kernel, which is what artools expects. For `linux-zen` (or `linux-lts`) the build swaps the kernel in a staged copy -of the profile and runs `buildiso` with a patched copy of the artools +of the profile. It always runs `buildiso` with a patched copy of the artools library (`LIBDIR`): artools reads the live kernel's version from -`/usr/src/linux/version`, which only the stock kernel's headers provide, and -the copy reads it from `/usr/lib/modules` instead. The installed artools -files are not modified. If artools changes that code, the build stops with a -message suggesting `-k linux`. +`/usr/src/linux/version`, which only a kernel's headers provide, and the +images carry no headers (only DKMS drivers need them, and Setup downloads +them with the NVIDIA driver), so the copy reads the version from +`/usr/lib/modules` instead. The installed artools files are not modified. +If artools changes that code, the build stops with a message. Needs: about 25 GB of free disk space, a network connection, and root rights for the container (`--privileged`, because `buildiso` mounts file diff --git a/docs/windows-to-seglinux.md b/docs/windows-to-seglinux.md index 6ac14b6..61ecc0c 100644 --- a/docs/windows-to-seglinux.md +++ b/docs/windows-to-seglinux.md @@ -58,7 +58,7 @@ prints it): | Virtual desktops (Win+Ctrl+Left/Right) | labwc workspaces, with the same shortcut; their number is set in Settings | | Xbox app / Game Bar, Game Mode | Steam, Lutris, MangoHud, GameMode and the SegLinux Gaming Mode session (see [gaming.md](gaming.md)) | | Window buttons (minimize, maximize, close on the right), Alt+Tab thumbnails | KWin defaults in `/etc/xdg/kwinrc` | -| File Explorer, Notepad, Calculator, Snipping Tool, Photos, Task Manager, Disk Management | Dolphin, Kate, KCalc, Spectacle, Gwenview, System Monitor, Partition Manager | +| File Explorer, Notepad, Calculator, Snipping Tool, Photos, Task Manager, Disk Management | Dolphin, Kate, KCalc, the desktop's own screenshots (Print, Super+Shift+S), Gwenview, System Monitor, Partition Manager | | Microsoft Store | Discover with Flathub (Flatpak) | | Windows Update | `sudo seglinux-upgrade` (see [updating.md](updating.md)) or Discover | | Edge, Office, Media Player | Brave, LibreOffice, VLC | diff --git a/iso/iso-profiles/common/common.yaml b/iso/iso-profiles/common/common.yaml index b03a451..f1b37f3 100644 --- a/iso/iso-profiles/common/common.yaml +++ b/iso/iso-profiles/common/common.yaml @@ -9,7 +9,9 @@ packages-base: - base - linux - linux-firmware - - linux-headers + # No kernel headers: 325 MiB that only DKMS drivers need. Setup downloads + # the running kernel's headers with the NVIDIA driver (the one DKMS + # module it installs). - intel-ucode - amd-ucode - mkinitcpio @@ -48,33 +50,18 @@ packages-base: - wpa_supplicant packages-apps: - - inxi - fastfetch +# Both sessions are Wayland (labwc, Plasma) and applications use XWayland, +# so the X server and its drivers are left out, as in the Lite edition. packages-xorg: - - xorg-server - - xf86-input-libinput - - xf86-input-vmmouse - - xf86-input-wacom - - xf86-video-amdgpu - - xf86-video-ati - - xf86-video-fbdev - - xf86-video-qxl - - xf86-video-vesa + - xorg-xwayland packages-xlibre: - - xlibre-xserver - - xlibre-input-vmmouse - - xlibre-input-wacom - - xlibre-video-amdgpu - - xlibre-video-ati - - xlibre-video-fbdev - - xlibre-video-qxl - - xlibre-video-vesa + - xorg-xwayland packages-misc: - wayland - - xorg-xwayland - xdg-user-dirs - xdg-utils - terminus-font diff --git a/iso/iso-profiles/seglinux/profile.yaml b/iso/iso-profiles/seglinux/profile.yaml index c0e19d6..af8885a 100644 --- a/iso/iso-profiles/seglinux/profile.yaml +++ b/iso/iso-profiles/seglinux/profile.yaml @@ -22,7 +22,6 @@ live-session: - acpid - bluetoothd - cronie - - cupsd - syslog-ng - NetworkManager - greetd @@ -76,17 +75,12 @@ rootfs: - print-manager # Devices and Printers - discover # Microsoft Store - flatpak - - xorg-server - - xorg-xwayland - - xdg-user-dirs - - xdg-utils # Accessories - dolphin # File Explorer - kitty # Terminal - kate # Notepad - kcalc # Calculator - - spectacle # Snipping Tool - gwenview # Photos - okular # PDF reader - ark # compressed folders @@ -120,7 +114,6 @@ rootfs: # Courier New, Calibri) so documents keep their layout - noto-fonts - noto-fonts-emoji - - ttf-liberation - ttf-carlito # Running Windows programs and games diff --git a/iso/scripts/build-in-container.sh b/iso/scripts/build-in-container.sh index cf571f3..130818d 100755 --- a/iso/scripts/build-in-container.sh +++ b/iso/scripts/build-in-container.sh @@ -131,9 +131,10 @@ fi # Kernel: common.yaml names the stock kernel, which artools expects; swap in # the requested one. artools derives the live initramfs' kernel version from -# /usr/src/linux/version, which only the stock linux-headers provides, so for -# other kernels buildiso runs with a patched copy of its library (buildiso -# honours LIBDIR) that reads the version from /usr/lib/modules instead. +# /usr/src/linux/version, which only a kernel's headers provide. The images +# carry no headers (only DKMS drivers need them, and Setup downloads those), +# so for every kernel buildiso runs with a patched copy of its library +# (buildiso honours LIBDIR) that reads the version from /usr/lib/modules. # Copy artools' library once so that buildiso (which honours LIBDIR) runs # with patched code; the installed artools files stay untouched. patched_lib() { @@ -156,12 +157,11 @@ patch_lib() { use_kernel() { sed -i -E "s/^(\s*-\s*)linux$/\1$kernel/; s/^(\s*-\s*)linux-headers$/\1$kernel-headers/" \ "$workspace/iso-profiles/common/common.yaml" - [[ $kernel == linux ]] && return - patched_lib || die "this buildiso does not honour LIBDIR; build with -k linux" + patched_lib || die "this buildiso does not honour LIBDIR; the images have no kernel headers for it to read the kernel version from" # shellcheck disable=SC2016 # literal shell code, not expansions patch_lib iso/initcpio.sh 'k=$(<"$mnt"/usr/src/linux/version)' \ 'k=$(basename "$(dirname "$(find "$mnt"/usr/lib/modules -mindepth 2 -maxdepth 2 -name vmlinuz | sort -V | tail -n1)")")' || - die "artools changed how it finds the kernel version in iso/initcpio.sh; build with -k linux" + die "artools changed how it finds the kernel version in iso/initcpio.sh" } step "Using the $kernel kernel" use_kernel From a4526e2e9df3156a9b27092401a882bd5c5e5c9f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:54:09 +0000 Subject: [PATCH 03/23] feat(seg): seg, one small Rust tool for the system (tools/seg) A workspace of its own (release profile: opt-level z, LTO, panic=abort, stripped; about 550 KiB) with the crate segctl and the binary seg. One dependency, lexopt. - seg info: distro, kernel, init, uptime, RAM, packages, disk. - seg svc list|enable|disable|start|stop: OpenRC, runit, dinit and s6, detected from PID 1, /run, then the installed files. - seg clean: orphans, pacman cache beyond the newest 2 versions (a port of libalpm's vercmp), rotated logs, ~/.cache size. Dry run by default. - seg tune light|balanced|performance: vm sysctls, CPU governor, I/O scheduler per disk type; saved to /etc/sysctl.d/99-seg.conf. - seg zram on|off|status: zstd swap in memory, half the RAM. - seg doctor: failed services, missing firmware, low disk space, a stale db.lck, a broken keyring, each with its fix. Root is checked up front, unwrap/expect/panic/indexing are compile errors outside tests, output ends quietly on a closed pipe. 77 unit tests cover every parser and the file-writing parts against a fake /sys, /proc, /etc. Also run in an Artix container: info, doctor, clean, tune, zram status, svc against the real dinit layout. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- .gitignore | 5 + Cargo.toml | 2 + docs/seg.md | 8 + tools/seg/Cargo.lock | 16 + tools/seg/Cargo.toml | 21 + tools/seg/README.md | 147 +++++ tools/seg/dist/99-seg.conf | 5 + tools/seg/dist/cron-fstrim | 3 + tools/seg/dist/dinit/seg-tune | 5 + tools/seg/dist/dinit/seg-zram | 5 + tools/seg/dist/fstrim-weekly | 31 + tools/seg/dist/openrc/seg-tune | 17 + tools/seg/dist/openrc/seg-zram | 23 + tools/seg/dist/runit/seg-tune/run | 6 + tools/seg/dist/runit/seg-zram/finish | 2 + tools/seg/dist/runit/seg-zram/run | 6 + tools/seg/dist/tune.conf | 3 + tools/seg/segctl/Cargo.toml | 17 + tools/seg/segctl/src/clean.rs | 498 +++++++++++++++ tools/seg/segctl/src/doctor.rs | 600 +++++++++++++++++++ tools/seg/segctl/src/error.rs | 109 ++++ tools/seg/segctl/src/info.rs | 213 +++++++ tools/seg/segctl/src/init.rs | 138 +++++ tools/seg/segctl/src/main.rs | 234 ++++++++ tools/seg/segctl/src/svc.rs | 735 +++++++++++++++++++++++ tools/seg/segctl/src/sys.rs | 336 +++++++++++ tools/seg/segctl/src/tune.rs | 866 +++++++++++++++++++++++++++ tools/seg/segctl/src/vercmp.rs | 244 ++++++++ tools/seg/segctl/src/zram.rs | 573 ++++++++++++++++++ 29 files changed, 4868 insertions(+) create mode 100644 docs/seg.md create mode 100644 tools/seg/Cargo.lock create mode 100644 tools/seg/Cargo.toml create mode 100644 tools/seg/README.md create mode 100644 tools/seg/dist/99-seg.conf create mode 100644 tools/seg/dist/cron-fstrim create mode 100644 tools/seg/dist/dinit/seg-tune create mode 100644 tools/seg/dist/dinit/seg-zram create mode 100755 tools/seg/dist/fstrim-weekly create mode 100755 tools/seg/dist/openrc/seg-tune create mode 100755 tools/seg/dist/openrc/seg-zram create mode 100755 tools/seg/dist/runit/seg-tune/run create mode 100755 tools/seg/dist/runit/seg-zram/finish create mode 100755 tools/seg/dist/runit/seg-zram/run create mode 100644 tools/seg/dist/tune.conf create mode 100644 tools/seg/segctl/Cargo.toml create mode 100644 tools/seg/segctl/src/clean.rs create mode 100644 tools/seg/segctl/src/doctor.rs create mode 100644 tools/seg/segctl/src/error.rs create mode 100644 tools/seg/segctl/src/info.rs create mode 100644 tools/seg/segctl/src/init.rs create mode 100644 tools/seg/segctl/src/main.rs create mode 100644 tools/seg/segctl/src/svc.rs create mode 100644 tools/seg/segctl/src/sys.rs create mode 100644 tools/seg/segctl/src/tune.rs create mode 100644 tools/seg/segctl/src/vercmp.rs create mode 100644 tools/seg/segctl/src/zram.rs diff --git a/.gitignore b/.gitignore index ac5b059..5c5dbe2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,11 @@ /packaging/seglinux/src/ /packaging/seglinux/pkg/ /packaging/seglinux/*.pkg.tar.* +/tools/seg/target/ +/pkg/segctl/*.tar.gz +/pkg/segctl/src/ +/pkg/segctl/pkg/ +/pkg/segctl/*.pkg.tar.* *.iso *.iso.sha256 _site/ diff --git a/Cargo.toml b/Cargo.toml index 1ed79e2..d725934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ members = [ "crates/seglinux-installer", "crates/seglinux-greeter", ] +# seg has its own workspace and release profile (tools/seg/Cargo.toml). +exclude = ["tools/seg"] [workspace.package] version = "0.1.0" diff --git a/docs/seg.md b/docs/seg.md new file mode 100644 index 0000000..7ad690e --- /dev/null +++ b/docs/seg.md @@ -0,0 +1,8 @@ +# The seg tool + +`seg` looks after a SegLinux system: `seg info`, `seg svc` (services on +OpenRC, runit, dinit or s6), `seg clean`, `seg tune`, `seg zram` and +`seg doctor`. It is a small Rust program in `tools/seg` and is installed from +the `segctl` package. + +Everything about it is in [tools/seg/README.md](../tools/seg/README.md). diff --git a/tools/seg/Cargo.lock b/tools/seg/Cargo.lock new file mode 100644 index 0000000..2bb4d03 --- /dev/null +++ b/tools/seg/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "lexopt" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413" + +[[package]] +name = "segctl" +version = "0.1.0" +dependencies = [ + "lexopt", +] diff --git a/tools/seg/Cargo.toml b/tools/seg/Cargo.toml new file mode 100644 index 0000000..0d29c44 --- /dev/null +++ b/tools/seg/Cargo.toml @@ -0,0 +1,21 @@ +# A workspace of its own: `seg` is a tiny, dependency-light tool with its own +# release profile (size over speed, no unwinding), unlike the GTK desktop +# crates in the repository's root workspace. +[workspace] +resolver = "3" +members = ["segctl"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +authors = ["SegLinux contributors"] +repository = "https://github.com/SegMind25/SegLinux" +homepage = "https://github.com/SegMind25/SegLinux" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/tools/seg/README.md b/tools/seg/README.md new file mode 100644 index 0000000..9e37d76 --- /dev/null +++ b/tools/seg/README.md @@ -0,0 +1,147 @@ +# seg + +One small Rust program to look after a SegLinux system, the same on OpenRC, +runit, dinit and s6. No async runtime and one dependency +([lexopt](https://crates.io/crates/lexopt), a minimal argument parser); the +release binary is about 550 KiB. + +``` +seg info distro, kernel, init, uptime, memory, packages, disk +seg svc list|enable|disable|start|stop [name] +seg clean [--apply] orphans, old cached packages, old logs +seg tune light|balanced|performance|apply|status +seg zram on|off|status +seg doctor look for problems and print their fixes +``` + +Commands that change the system need root and say so ("`seg tune +performance` needs root. Run it again with sudo"); errors are messages, not +panics. Reports (`info`, `svc list`, `tune status`, `zram status`, `doctor`, +`clean` without `--apply`) work as a normal user. + +## seg info + +``` +SegLinux + Kernel 7.2.11-zen1-1-zen + Init openrc + Uptime 2h 15m + Memory 1.9 GiB / 15.6 GiB (12%) + Packages 912 (pacman) + Disk / 21.4 GiB / 232.9 GiB (9%) +``` + +It reads /proc, /etc/os-release and pacman's local database directly; the only +program it starts is `df`. + +## seg svc + +One command for the four init systems, detected from PID 1 (`openrc-init`, +`runit`, `dinit`, `s6-svscan`), then from the state the init leaves in /run, +then from what is installed. + +| | OpenRC | runit | dinit | s6 | +|---|---|---|---|---| +| `enable` | `/etc/runlevels//` -> `/etc/init.d/` | `/etc/runit/runsvdir/default/` -> `/etc/runit/sv/` | `/etc/dinit.d/boot.d/` -> `../` | `/etc/s6/adminsv/default/contents.d/`, then `s6-db-reload` | +| `start`, `stop` | `rc-service` | `sv` | `dinitctl` | `s6-rc -u/-d change` | +| state in `list` | `rc-status -s` | `sv status` | `dinitctl list` | `s6-rc -a list` | + +`enable` and `disable` only create or remove those links, so they also work in +a chroot or on a mounted system (`--offline` picks the init from what is +installed there instead of from the running PID 1). Service names are checked: +no paths, no hidden files. + +## seg clean + +Dry run unless `--apply` is given. + +* **orphans**: `pacman -Qdtq`; applied with `pacman -Rns`. +* **package cache**: for every package and architecture, all but the newest 2 + versions (`--keep N`), with their `.sig` files. The versions are ordered + with a port of libalpm's `vercmp` (epochs, `1.10 > 1.2`, `1.0a < 1.0`), so it + keeps what `paccache -k2` would. `CacheDir` is read from pacman.conf. + Partial downloads and anything that is not a finished package are left alone. +* **logs**: rotated logs (`messages.1`, `*.log.old`, `*-20240101.gz`) in + /var/log older than 30 days (`--log-days N`). Live logs are never touched. +* **~/.cache**: its size and the five biggest folders (of the user who ran + `sudo`). Reported, never deleted. + +## seg tune + +| | light | balanced | performance | +|---|---|---|---| +| `vm.swappiness` | 100 | 60 | 10 | +| `vm.vfs_cache_pressure` | 150 | 100 | 50 | +| `vm.dirty_ratio` | 10 | 20 | 15 | +| `vm.dirty_background_ratio` | 5 | 10 | 5 | +| CPU governor (first available) | powersave, conservative, schedutil, ondemand | schedutil, ondemand, powersave | performance | +| energy/performance preference | power | balance_performance | performance | + +* With zram swap on, swappiness is at least 100: swapping to compressed memory + is cheaper than re-reading files from disk. +* I/O scheduler of every disk: `none` for NVMe and virtual disks (their + controller or host schedules), `mq-deadline` for SSDs, `bfq` for spinning + disks. A scheduler the kernel does not have is reported, not forced. +* The profile is applied at once and saved: /etc/seg/tune.conf (the profile) and + /etc/sysctl.d/99-seg.conf (the values, for the `sysctl` service). The + `seg-tune` service runs `seg tune apply` after boot to set what only + exists at run time (governor, schedulers). +* `power-profiles-daemon`, when it runs, also sets the CPU's energy preference; + `seg-tune` starts after it, so the saved profile wins at boot and the last + change wins afterwards. +* `--dry-run` shows the changes without making them. + +## seg zram + +`seg zram on` loads the module if needed and makes /dev/zram0 swap: zstd, +half the RAM (at most 8 GiB, because the device's table uses memory in +proportion to its size; `--size 2G` overrides), priority 100. It also turns +zswap off (it would compress the same pages twice) and sets +`vm.page-cluster=0`. `seg zram off` swaps off and frees the memory; `status` +shows size, use and compression ratio. The `seg-zram` service runs it at boot. + +## seg doctor + +| check | looks at | fix it prints | +|---|---|---| +| services | enabled services that are stopped or crashed | the init's start command, or `seg svc disable` | +| firmware | `dmesg`: "Direct firmware load for ... failed with error -2" | `pacman -F` to find the package, then install and reboot | +| disk | /, /home, /var, /usr under 1 GiB free or 90 % full; /boot under 20 MiB | `seg clean --apply` | +| pacman | /var/lib/pacman/db.lck with no pacman running | `sudo rm /var/lib/pacman/db.lck` | +| keyring | /etc/pacman.d/gnupg, `artix-keyring`, Artix (and Arch, if enabled) keys | `pacman-key --init` / `--populate artix` | + +Exit status 1 when it found a problem. The kernel log and the keyring need +root to read (`sudo seg doctor`); without it those checks say "skip". +Some drivers (iwlwifi, amdgpu) ask for a newer firmware file first and fall +back, which also appears in the log; the report says so. + +## Shipped in the package (`pkg/segctl`) + +* `/usr/bin/seg` +* `seg-tune` and `seg-zram` for OpenRC (`/etc/init.d`), runit + (`/etc/runit/sv`) and dinit (`/etc/dinit.d`); the package enables both + (`seg svc --offline enable`) when it is installed, also in the ISO build's chroot +* `/etc/seg/tune.conf` and `/etc/sysctl.d/99-seg.conf`: the balanced profile +* `/usr/lib/seg/fstrim-weekly` and `/etc/cron.d/seg-fstrim`: cron checks every + hour and trims once a week (a stamp in /var/lib/seg), so a computer that + was off on the usual day catches up + +## Building + +```sh +cd tools/seg +cargo fmt --check && cargo clippy -- -D warnings && cargo test +cargo build --release # target/release/seg +make -C ../../pkg/segctl package # the pacman package, on Arch/Artix +``` + +The release profile is `opt-level = "z"`, LTO, one codegen unit, +`panic = "abort"`, stripped. Outside tests, `unwrap`, `expect`, `panic!` and +slice indexing are compile errors (`#![deny(clippy::...)]`), and output goes +through a writer that ends quietly on a closed pipe (`seg clean | head`). + +The code is split so that every parser is a pure function with tests +(`/proc/meminfo`, `df -Pk`, `rc-status`, `sv status`, `dinitctl list`, +`/proc/swaps`, `dmesg`, pacman cache file names, version order), and the +parts that write to /sys, /proc and /etc take a root directory, which the +tests point at a fake tree. diff --git a/tools/seg/dist/99-seg.conf b/tools/seg/dist/99-seg.conf new file mode 100644 index 0000000..93d20fc --- /dev/null +++ b/tools/seg/dist/99-seg.conf @@ -0,0 +1,5 @@ +# Written by `seg tune balanced`; change it with `sudo seg tune `. +vm.swappiness = 60 +vm.vfs_cache_pressure = 100 +vm.dirty_ratio = 20 +vm.dirty_background_ratio = 10 diff --git a/tools/seg/dist/cron-fstrim b/tools/seg/dist/cron-fstrim new file mode 100644 index 0000000..0dfe3d2 --- /dev/null +++ b/tools/seg/dist/cron-fstrim @@ -0,0 +1,3 @@ +# Weekly TRIM for SSDs (see /usr/lib/seg/fstrim-weekly): checked every hour, +# done once a week. +17 * * * * root /usr/lib/seg/fstrim-weekly diff --git a/tools/seg/dist/dinit/seg-tune b/tools/seg/dist/dinit/seg-tune new file mode 100644 index 0000000..124fd58 --- /dev/null +++ b/tools/seg/dist/dinit/seg-tune @@ -0,0 +1,5 @@ +# Applies the saved `seg tune` profile (CPU governor, I/O schedulers and +# memory settings) at boot. Choose the profile with: sudo seg tune +type = scripted +command = /usr/bin/seg tune apply +restart = false diff --git a/tools/seg/dist/dinit/seg-zram b/tools/seg/dist/dinit/seg-zram new file mode 100644 index 0000000..fdff5b7 --- /dev/null +++ b/tools/seg/dist/dinit/seg-zram @@ -0,0 +1,5 @@ +# Compressed swap in memory (zram): zstd, half the RAM. See: seg zram status +type = scripted +command = /usr/bin/seg zram on +stop-command = /usr/bin/seg zram off +restart = false diff --git a/tools/seg/dist/fstrim-weekly b/tools/seg/dist/fstrim-weekly new file mode 100755 index 0000000..295890b --- /dev/null +++ b/tools/seg/dist/fstrim-weekly @@ -0,0 +1,31 @@ +#!/bin/sh +# Trims the free space of every mounted file system that supports it, at most +# once a week. cron starts this every hour (/etc/cron.d/seg-fstrim); the stamp +# makes it run once, and a computer that was off on the usual day catches up +# when it is next on. TRIM keeps SSDs fast; on other drives it does nothing. +set -eu +PATH=/usr/bin:/usr/sbin:/bin:/sbin +stamp=/var/lib/seg/fstrim-last +week=604800 + +now=$(date +%s) +if [ -r "$stamp" ]; then + last=$(cat "$stamp" 2>/dev/null || echo 0) + case $last in + '' | *[!0-9]*) last=0 ;; + esac + [ $((now - last)) -ge $week ] || exit 0 +fi + +mkdir -p "$(dirname "$stamp")" +# Lowest CPU and disk priority: this runs while someone may be playing. +if command -v ionice >/dev/null 2>&1; then + set -- ionice -c3 nice -n 19 fstrim -Av +else + set -- nice -n 19 fstrim -Av +fi +# fstrim exits non-zero when one file system cannot be trimmed (a RAM disk, +# a drive without TRIM): the others are still done. +out=$("$@" 2>&1) || true +printf '%s\n' "$out" | logger -t seg-fstrim 2>/dev/null || true +printf '%s\n' "$now" > "$stamp" diff --git a/tools/seg/dist/openrc/seg-tune b/tools/seg/dist/openrc/seg-tune new file mode 100755 index 0000000..8d82408 --- /dev/null +++ b/tools/seg/dist/openrc/seg-tune @@ -0,0 +1,17 @@ +#!/usr/bin/openrc-run +# shellcheck shell=sh disable=SC2034 +# Applies the saved `seg tune` profile (CPU governor, I/O schedulers and +# memory settings) at boot. Choose the profile with: sudo seg tune + +description="Apply the saved seg tune profile" + +depend() { + # Last writer wins: let power-profiles-daemon and the zram swap settle first. + after sysctl udev power-profiles-daemon seg-zram +} + +start() { + ebegin "Applying the seg tune profile" + /usr/bin/seg tune apply >/dev/null + eend $? +} diff --git a/tools/seg/dist/openrc/seg-zram b/tools/seg/dist/openrc/seg-zram new file mode 100755 index 0000000..88a2306 --- /dev/null +++ b/tools/seg/dist/openrc/seg-zram @@ -0,0 +1,23 @@ +#!/usr/bin/openrc-run +# shellcheck shell=sh disable=SC2034 +# Compressed swap in memory (zram): zstd, half the RAM, used before any disk +# swap. See: seg zram status + +description="Compressed swap in memory (zram)" + +depend() { + after modules sysctl udev + before seg-tune +} + +start() { + ebegin "Setting up zram swap" + /usr/bin/seg zram on >/dev/null + eend $? +} + +stop() { + ebegin "Stopping zram swap" + /usr/bin/seg zram off >/dev/null + eend $? +} diff --git a/tools/seg/dist/runit/seg-tune/run b/tools/seg/dist/runit/seg-tune/run new file mode 100755 index 0000000..19bb6e1 --- /dev/null +++ b/tools/seg/dist/runit/seg-tune/run @@ -0,0 +1,6 @@ +#!/bin/sh +# Applies the saved `seg tune` profile once, then stays "up" so runit does +# not start it again. +exec 2>&1 +/usr/bin/seg tune apply +exec sleep infinity diff --git a/tools/seg/dist/runit/seg-zram/finish b/tools/seg/dist/runit/seg-zram/finish new file mode 100755 index 0000000..bf08720 --- /dev/null +++ b/tools/seg/dist/runit/seg-zram/finish @@ -0,0 +1,2 @@ +#!/bin/sh +exec /usr/bin/seg zram off diff --git a/tools/seg/dist/runit/seg-zram/run b/tools/seg/dist/runit/seg-zram/run new file mode 100755 index 0000000..224ddf3 --- /dev/null +++ b/tools/seg/dist/runit/seg-zram/run @@ -0,0 +1,6 @@ +#!/bin/sh +# Sets up zram swap once, then stays "up" so runit does not start it again. +# `sv stop seg-zram` runs ./finish, which turns it off. +exec 2>&1 +/usr/bin/seg zram on +exec sleep infinity diff --git a/tools/seg/dist/tune.conf b/tools/seg/dist/tune.conf new file mode 100644 index 0000000..0da72f1 --- /dev/null +++ b/tools/seg/dist/tune.conf @@ -0,0 +1,3 @@ +# seg tune profile: light, balanced or performance. +# Change it with `sudo seg tune `; the seg-tune service applies it at boot. +mode = balanced diff --git a/tools/seg/segctl/Cargo.toml b/tools/seg/segctl/Cargo.toml new file mode 100644 index 0000000..00b608c --- /dev/null +++ b/tools/seg/segctl/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "segctl" +description = "seg: system information, services, cleaning, tuning, zram and checks for SegLinux, over runit, OpenRC, dinit and s6" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +license = "LicenseRef-SegLinux-unspecified" +publish = false + +[[bin]] +name = "seg" +path = "src/main.rs" + +[dependencies] +lexopt = "0.3" diff --git a/tools/seg/segctl/src/clean.rs b/tools/seg/segctl/src/clean.rs new file mode 100644 index 0000000..e80b73e --- /dev/null +++ b/tools/seg/segctl/src/clean.rs @@ -0,0 +1,498 @@ +//! `seg clean`: orphans, the pacman cache, old logs and the size of +//! ~/.cache. A dry run unless `--apply` is given. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use crate::error::{Context, Error, Result}; +use crate::sys::{self, bold, human_bytes}; +use crate::vercmp::{self, parse_cache_file}; + +pub const HELP: &str = "\ +Usage: seg clean [--apply] [--keep N] [--log-days N] + +Finds what can go: packages nothing needs any more (pacman -Qdtq), cached +packages beyond the newest N versions (default 2, like paccache -k2), +rotated logs older than N days (default 30), and shows how big ~/.cache is. + +By default nothing is changed: it only lists. --apply deletes (as root); +--dry-run is accepted for clarity and is the default. +"; + +#[derive(Debug)] +pub struct Options { + pub apply: bool, + pub keep: usize, + pub log_days: u64, +} + +impl Default for Options { + fn default() -> Self { + Options { + apply: false, + keep: 2, + log_days: 30, + } + } +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut o = Options::default(); + while let Some(arg) = parser.next()? { + match arg { + Long("apply") => o.apply = true, + Long("dry-run") => o.apply = false, + Long("keep") => o.keep = parser.value()?.parse()?, + Long("log-days") => o.log_days = parser.value()?.parse()?, + Short('h') | Long("help") => { + out!("{HELP}"); + std::process::exit(0); + } + other => return Err(other.unexpected().into()), + } + } + Ok(o) +} + +/// `CacheDir` lines of pacman.conf; the default when there are none. +pub fn parse_cache_dirs(conf: &str) -> Vec { + let dirs: Vec = conf + .lines() + .filter_map(|l| { + let l = l.trim(); + let (key, value) = l.split_once('=')?; + (key.trim() == "CacheDir").then(|| PathBuf::from(value.trim())) + }) + .collect(); + if dirs.is_empty() { + vec![PathBuf::from("/var/cache/pacman/pkg")] + } else { + dirs + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct CachePlan { + pub remove: Vec, + pub bytes: u64, + pub kept: usize, +} + +/// Which package files go: for every package (and architecture), all but the +/// newest `keep` versions, together with their signatures. `files` are +/// (path, size) pairs. +pub fn plan_cache(files: &[(PathBuf, u64)], keep: usize) -> CachePlan { + // (name, arch) -> version -> files; signatures follow the package. + type Versions<'a> = BTreeMap>; + let mut groups: BTreeMap<(String, String), Versions> = BTreeMap::new(); + for (path, size) in files { + let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Some(parsed) = parse_cache_file(file_name) else { + continue; + }; + groups + .entry((parsed.name, parsed.arch)) + .or_default() + .entry(parsed.version) + .or_default() + .push((path.as_path(), *size)); + } + let mut plan = CachePlan::default(); + for versions in groups.into_values() { + let mut versions: Vec<_> = versions.into_iter().collect(); + // Newest first. + versions.sort_by(|a, b| vercmp::vercmp(&b.0, &a.0)); + for (index, (_, paths)) in versions.into_iter().enumerate() { + if index < keep { + plan.kept += 1; + continue; + } + for (path, size) in paths { + plan.remove.push(path.to_path_buf()); + plan.bytes += size; + } + } + } + plan.remove.sort(); + plan +} + +fn cache_files(dir: &Path) -> Vec<(PathBuf, u64)> { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|e| { + let meta = e.metadata().ok()?; + meta.is_file().then(|| (e.path(), meta.len())) + }) + .collect() +} + +/// Rotated logs: `messages.1`, `Xorg.0.log.old`, `syslog-20240101`, +/// `pacman.log.1.gz`, `auth.log-2024.xz`. The live log files are never +/// matched. +pub fn is_rotated_log(name: &str) -> bool { + let compressed = [".gz", ".xz", ".zst", ".bz2", ".lz4"] + .iter() + .find_map(|ext| name.strip_suffix(ext)); + let stem = compressed.unwrap_or(name); + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + if stem.ends_with(".old") { + return true; + } + // "name.1", "name.12" + if let Some((base, n)) = stem.rsplit_once('.') + && !base.is_empty() + && digits(n) + { + return true; + } + // "name-20240101", "name-2024" + if let Some((base, date)) = stem.rsplit_once('-') + && !base.is_empty() + && date.len() >= 4 + && digits(date) + { + return true; + } + // A compressed copy of a log. + compressed.is_some() && stem.contains(".log") +} + +fn collect_old_logs(dir: &Path, depth: u32, older_than: Duration, out: &mut Vec<(PathBuf, u64)>) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + let now = SystemTime::now(); + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = fs::symlink_metadata(&path) else { + continue; + }; + if meta.is_dir() { + if depth > 0 { + collect_old_logs(&path, depth - 1, older_than, out); + } + continue; + } + let rotated = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(is_rotated_log); + let old = meta + .modified() + .ok() + .and_then(|m| now.duration_since(m).ok()) + .is_some_and(|age| age >= older_than); + if meta.is_file() && rotated && old { + out.push((path, meta.len())); + } + } +} + +fn orphans() -> Option> { + sys::which("pacman")?; + // pacman -Qdtq exits 1 with no output when there are none. + let out = sys::run("pacman", &["-Qdtq"]).ok()?; + Some( + out.stdout + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(String::from) + .collect(), + ) +} + +fn print_list(items: &[PathBuf]) { + const SHOWN: usize = 8; + for p in items.iter().take(SHOWN) { + outln!(" {}", p.display()); + } + if items.len() > SHOWN { + outln!(" ... and {} more", items.len() - SHOWN); + } +} + +pub fn run(o: Options) -> Result<()> { + if o.apply { + sys::require_root("`seg clean --apply`")?; + if Path::new("/var/lib/pacman/db.lck").exists() { + return Err(Error::other( + "pacman is locked (/var/lib/pacman/db.lck). If no pacman runs, `seg doctor` explains how to clear it.", + )); + } + } + let verb = if o.apply { "Removing" } else { "Would remove" }; + outln!( + "{}", + bold(if o.apply { + "seg clean" + } else { + "seg clean (dry run: nothing is changed; --apply deletes)" + }) + ); + + // Orphans. + let orphans = orphans(); + match &orphans { + None => outln!(" orphans: pacman not found"), + Some(list) if list.is_empty() => outln!(" orphans: none"), + Some(list) => { + outln!( + " orphans: {} packages nothing needs ({})", + list.len(), + list.join(" ") + ); + } + } + + // Package cache. + let conf = fs::read_to_string("/etc/pacman.conf").unwrap_or_default(); + let mut cache = CachePlan::default(); + for dir in parse_cache_dirs(&conf) { + let plan = plan_cache(&cache_files(&dir), o.keep); + cache.kept += plan.kept; + cache.bytes += plan.bytes; + cache.remove.extend(plan.remove); + } + outln!( + " package cache: {verb} {} files ({}), keeping the newest {} versions of each package", + cache.remove.len(), + human_bytes(cache.bytes), + o.keep + ); + print_list(&cache.remove); + + // Logs. + let mut logs = Vec::new(); + collect_old_logs( + Path::new("/var/log"), + 3, + Duration::from_secs(o.log_days * 86_400), + &mut logs, + ); + let log_bytes: u64 = logs.iter().map(|(_, s)| s).sum(); + let log_paths: Vec = logs.into_iter().map(|(p, _)| p).collect(); + outln!( + " logs: {verb} {} rotated logs older than {} days ({})", + log_paths.len(), + o.log_days, + human_bytes(log_bytes) + ); + print_list(&log_paths); + + // ~/.cache: reported, never deleted. + if let Some(home) = sys::invoking_home() { + let cache_dir = home.join(".cache"); + let mut subdirs: Vec<(u64, String)> = fs::read_dir(&cache_dir) + .map(|d| { + d.flatten() + .map(|e| { + ( + sys::dir_size(&e.path()), + e.file_name().to_string_lossy().into_owned(), + ) + }) + .collect() + }) + .unwrap_or_default(); + let total: u64 = subdirs.iter().map(|(s, _)| s).sum(); + outln!( + " {}: {} (never deleted by seg)", + cache_dir.display(), + human_bytes(total) + ); + subdirs.sort_by(|a, b| b.cmp(a)); + for (size, name) in subdirs.into_iter().take(5) { + outln!(" {:>10} {name}", human_bytes(size)); + } + } + + if o.apply { + for path in cache.remove.iter().chain(&log_paths) { + fs::remove_file(path).context(format!("removing {}", path.display()))?; + } + if let Some(list) = orphans.filter(|l| !l.is_empty()) { + let mut args = vec!["-Rns", "--noconfirm"]; + args.extend(list.iter().map(String::as_str)); + sys::run_inherit("pacman", &args)?; + } + outln!("Done."); + } else { + let total = cache.bytes + log_bytes; + outln!( + "\nSegLinux could free {} of cache and logs. Run `sudo seg clean --apply` to do it.", + human_bytes(total) + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + fn files(names: &[&str]) -> Vec<(PathBuf, u64)> { + names + .iter() + .map(|n| (PathBuf::from("/c").join(n), 100)) + .collect() + } + + fn removed(plan: &CachePlan) -> Vec { + plan.remove + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn keeps_the_newest_two_versions() { + let f = files(&[ + "foo-1.0-1-x86_64.pkg.tar.zst", + "foo-1.0-1-x86_64.pkg.tar.zst.sig", + "foo-1.1-1-x86_64.pkg.tar.zst", + "foo-1.2-1-x86_64.pkg.tar.zst", + "foo-1.10-1-x86_64.pkg.tar.zst", + "bar-2.0-1-x86_64.pkg.tar.zst", + ]); + let plan = plan_cache(&f, 2); + // 1.10 and 1.2 stay (1.10 is newer than 1.2, not older as text). + assert_eq!( + removed(&plan), + [ + "foo-1.0-1-x86_64.pkg.tar.zst", + "foo-1.0-1-x86_64.pkg.tar.zst.sig", + "foo-1.1-1-x86_64.pkg.tar.zst" + ] + ); + assert_eq!(plan.bytes, 300); + assert_eq!(plan.kept, 3); + } + + #[test] + fn epochs_and_releases_order_versions() { + let f = files(&[ + "qt-6.7-1-x86_64.pkg.tar.zst", + "qt-1:5.15-1-x86_64.pkg.tar.zst", + "qt-6.7-2-x86_64.pkg.tar.zst", + ]); + let plan = plan_cache(&f, 1); + // The epoch makes 5.15 the newest. + assert_eq!( + removed(&plan), + ["qt-6.7-1-x86_64.pkg.tar.zst", "qt-6.7-2-x86_64.pkg.tar.zst"] + ); + } + + #[test] + fn architectures_and_names_are_separate_groups() { + let f = files(&[ + "lib-1.0-1-x86_64.pkg.tar.zst", + "lib-1.0-1-any.pkg.tar.zst", + "lib-2.0-1-x86_64.pkg.tar.zst", + "lib-git-1.0-1-x86_64.pkg.tar.zst", + ]); + assert!( + plan_cache(&f, 1) + .remove + .iter() + .all(|p| p.ends_with("lib-1.0-1-x86_64.pkg.tar.zst")) + ); + assert!(plan_cache(&f, 2).remove.is_empty()); + } + + #[test] + fn foreign_files_are_never_planned() { + let f = files(&[ + "download-x", + "foo-1.0-1-x86_64.pkg.tar.zst.part", + "notes.txt", + ]); + assert_eq!(plan_cache(&f, 0), CachePlan::default()); + } + + #[test] + fn keep_zero_removes_everything() { + let f = files(&["a-1-1-any.pkg.tar.xz", "a-2-1-any.pkg.tar.xz"]); + assert_eq!(plan_cache(&f, 0).remove.len(), 2); + } + + #[test] + fn cache_dirs_from_pacman_conf() { + let conf = "[options]\n#CacheDir = /nope\nCacheDir = /var/cache/pacman/pkg/\nCacheDir=/mnt/cache\nHoldPkg = pacman\n"; + assert_eq!( + parse_cache_dirs(conf), + [ + PathBuf::from("/var/cache/pacman/pkg/"), + PathBuf::from("/mnt/cache") + ] + ); + assert_eq!( + parse_cache_dirs("[options]\n"), + [PathBuf::from("/var/cache/pacman/pkg")] + ); + } + + #[test] + fn rotated_logs_only() { + for yes in [ + "messages.1", + "messages.12", + "Xorg.0.log.old", + "syslog-20240101.gz", + "pacman.log.1.gz", + "auth.log-2024.xz", + "daemon.log.3.zst", + ] { + assert!(is_rotated_log(yes), "{yes}"); + } + for no in [ + "messages", + "pacman.log", + "Xorg.0.log", + "wtmp", + "lastlog", + "syslog.gz.bak", + "1", + ".1", + "kern.log", + ] { + assert!(!is_rotated_log(no), "{no}"); + } + } + + #[test] + fn old_rotated_logs_are_found_below_the_depth_limit() { + let t = TempDir::new(); + t.put("messages", "live"); + t.put("messages.1", "old"); + t.put("sub/a.log.2", "old"); + t.put("sub/deep/er/est/z.1", "too deep"); + let mut found = Vec::new(); + // Everything is brand new, so a zero age finds it and a day does not. + collect_old_logs(t.path(), 3, Duration::from_secs(86_400), &mut found); + assert!(found.is_empty()); + collect_old_logs(t.path(), 2, Duration::ZERO, &mut found); + let mut names: Vec<_> = found + .iter() + .map(|(p, _)| { + p.strip_prefix(t.path()) + .unwrap() + .to_string_lossy() + .into_owned() + }) + .collect(); + names.sort(); + assert_eq!(names, ["messages.1", "sub/a.log.2"]); + } +} diff --git a/tools/seg/segctl/src/doctor.rs b/tools/seg/segctl/src/doctor.rs new file mode 100644 index 0000000..6ac97c9 --- /dev/null +++ b/tools/seg/segctl/src/doctor.rs @@ -0,0 +1,600 @@ +//! `seg doctor`: looks for the problems that most often break an Artix +//! system and prints the fix for each one it finds. Read-only. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use crate::error::Result; +use crate::info::{self, DfRow}; +use crate::init; +use crate::svc::{Service, State, Svc}; +use crate::sys::{self, bold, human_bytes}; + +pub const HELP: &str = "\ +Usage: seg doctor + +Checks for: services that are enabled but not running, firmware the kernel +could not load, low disk space, a stale pacman lock (db.lck) and a missing +or incomplete pacman keyring. For each problem it prints the command that +fixes it. Exits with status 1 when it found something. + +Some checks need root to read the kernel log and the keyring: run +`sudo seg doctor` to get all of them. +"; + +#[derive(Debug, PartialEq, Eq)] +pub struct Problem { + pub detail: String, + pub fix: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Outcome { + Healthy(String), + Problems(Vec), + /// The check could not run here. + Skipped(String), +} + +/// Services that start at boot but are not running. +pub fn failed_services(list: &[Service]) -> Vec<&Service> { + list.iter() + .filter(|s| s.enabled && matches!(s.state, State::Crashed | State::Stopped)) + .collect() +} + +fn services_problems(list: &[Service], init: init::Init) -> Outcome { + let failed = failed_services(list); + if failed.is_empty() { + let known = list.iter().any(|s| s.state != State::Unknown); + return Outcome::Healthy(if known { + "every enabled service is running".into() + } else { + "no service state to check".into() + }); + } + let start = |name: &str| match init { + init::Init::OpenRc => { + format!("sudo rc-service {name} start (then: rc-service {name} status)") + } + init::Init::Runit => format!( + "sudo sv start /etc/runit/runsvdir/default/{name} (log: /var/log/{name}/current)" + ), + init::Init::Dinit => format!("sudo dinitctl start {name} (log: dinitctl catlog {name})"), + init::Init::S6 => format!("sudo s6-rc -u change {name}"), + }; + Outcome::Problems( + failed + .into_iter() + .map(|s| Problem { + detail: format!("{} is enabled but {}", s.name, s.state.label()), + fix: format!( + "{}; if it should not start at boot: sudo seg svc disable {}", + start(&s.name), + s.name + ), + }) + .collect(), + ) +} + +/// Whether a log line reports error -2 (ENOENT), and not -22 or -20. +fn is_enoent(line: &str) -> bool { + ["error -2", "(-2"].iter().any(|needle| { + line.match_indices(needle).any(|(at, _)| { + !line + .as_bytes() + .get(at + needle.len()) + .is_some_and(u8::is_ascii_digit) + }) + }) +} + +/// File names the kernel could not load firmware for, from `dmesg`. +pub fn parse_missing_firmware(dmesg: &str) -> BTreeSet { + let mut files = BTreeSet::new(); + for line in dmesg.lines() { + let found = if let Some(rest) = line.split("Direct firmware load for ").nth(1) { + rest.split_whitespace().next() + } else if let Some(rest) = line.split("firmware: failed to load ").nth(1) { + rest.split_whitespace().next() + } else if let Some(rest) = line.split("Failed to load firmware \"").nth(1) { + rest.split('"').next() + } else { + None + }; + if let Some(name) = found.map(|n| n.trim_end_matches([',', '.', ';'])) { + // Only "file not found" (-2) is a missing file; other errors mean + // a bad file, which a package cannot fix. + let missing = is_enoent(line) || line.contains("Failed to load firmware"); + if !name.is_empty() && missing { + files.insert(name.to_string()); + } + } + } + files +} + +fn firmware_outcome(dmesg: Option<&str>) -> Outcome { + let Some(text) = dmesg else { + return Outcome::Skipped("cannot read the kernel log; run `sudo seg doctor`".into()); + }; + let files = parse_missing_firmware(text); + if files.is_empty() { + return Outcome::Healthy("the kernel found all the firmware it asked for".into()); + } + let first = files.iter().next().cloned().unwrap_or_default(); + Outcome::Problems(vec![Problem { + detail: format!( + "the kernel could not load {} firmware file{}: {}. Drivers that try a newer version first and fall back (iwlwifi, amdgpu) are harmless when the device works", + files.len(), + if files.len() == 1 { "" } else { "s" }, + files.iter().take(6).cloned().collect::>().join(", ") + + if files.len() > 6 { ", ..." } else { "" } + ), + fix: format!( + "sudo pacman -Fy && pacman -F usr/lib/firmware/{first} (install the package it names, or linux-firmware), then reboot" + ), + }]) +} + +const BOOT_MOUNTS: [&str; 3] = ["/boot", "/boot/efi", "/efi"]; +const WATCHED_MOUNTS: [&str; 4] = ["/", "/home", "/var", "/usr"]; + +/// File systems that are nearly full. The boot partition is small by design, +/// so it only counts when it has almost nothing left. +pub fn low_space(rows: &[DfRow]) -> Vec<&DfRow> { + const GIB_KIB: u64 = 1024 * 1024; + rows.iter() + .filter(|r| { + if BOOT_MOUNTS.contains(&r.mount.as_str()) { + r.avail_kib < 20 * 1024 + } else if WATCHED_MOUNTS.contains(&r.mount.as_str()) { + r.avail_kib < GIB_KIB || (r.use_percent >= 90 && r.avail_kib < 5 * GIB_KIB) + } else { + false + } + }) + .collect() +} + +fn disk_outcome(rows: &[DfRow]) -> Outcome { + if rows.is_empty() { + return Outcome::Skipped("`df` gave no answer".into()); + } + let low = low_space(rows); + if low.is_empty() { + return Outcome::Healthy("enough free space on /, /home, /var and /boot".into()); + } + Outcome::Problems( + low.into_iter() + .map(|r| Problem { + detail: format!( + "{} is {}% full, {} left", + r.mount, + r.use_percent, + human_bytes(r.avail_kib * 1024) + ), + fix: if BOOT_MOUNTS.contains(&r.mount.as_str()) { + "remove old kernels and initramfs images you do not boot (pacman -Rns the old kernel package)".into() + } else { + "sudo seg clean --apply (then: du -xh --max-depth=1 / | sort -h | tail)".into() + }, + }) + .collect(), + ) +} + +/// A lock is stale when pacman is not running. +pub fn lock_is_stale(lock_exists: bool, pacman_running: bool) -> bool { + lock_exists && !pacman_running +} + +fn pacman_running(proc: &Path) -> bool { + let Ok(entries) = fs::read_dir(proc) else { + return false; + }; + entries.flatten().any(|e| { + e.file_name() + .to_string_lossy() + .bytes() + .all(|b| b.is_ascii_digit()) + && sys::read_trimmed(e.path().join("comm")).as_deref() == Some("pacman") + }) +} + +fn lock_outcome(lock: &Path, proc: &Path) -> Outcome { + let exists = lock.exists(); + if !exists { + return Outcome::Healthy("pacman is not locked".into()); + } + if lock_is_stale(exists, pacman_running(proc)) { + Outcome::Problems(vec![Problem { + detail: format!("{} exists but pacman is not running", lock.display()), + fix: format!("sudo rm {}", lock.display()), + }]) + } else { + Outcome::Healthy("pacman is running (its lock is in use)".into()) + } +} + +/// Section names of pacman.conf, without `options`. +pub fn enabled_repos(conf: &str) -> Vec { + conf.lines() + .filter_map(|l| { + let l = l.trim(); + let name = l.strip_prefix('[')?.strip_suffix(']')?; + (name != "options").then(|| name.to_string()) + }) + .collect() +} + +pub enum Keys<'a> { + Listed(&'a str), + Failed(&'a str), + Unavailable, +} + +pub struct KeyringState<'a> { + pub gnupg_dir: bool, + pub artix_keyring_installed: bool, + pub keys: Keys<'a>, + pub arch_repos: bool, +} + +pub fn analyze_keyring(k: &KeyringState) -> Vec { + let mut problems = Vec::new(); + let reinit = "sudo pacman-key --init && sudo pacman-key --populate artix"; + if !k.gnupg_dir { + problems.push(Problem { + detail: "pacman's keyring does not exist (/etc/pacman.d/gnupg)".into(), + fix: reinit.into(), + }); + } + if !k.artix_keyring_installed { + problems.push(Problem { + detail: "the artix-keyring package is not installed".into(), + fix: "sudo pacman -Sy artix-keyring && sudo pacman-key --populate artix".into(), + }); + } + match k.keys { + Keys::Listed(text) => { + if !text.contains("Artix") { + problems.push(Problem { + detail: "the keyring has no Artix packager keys, so packages fail with \"signature is unknown trust\"".into(), + fix: "sudo pacman-key --populate artix".into(), + }); + } + if k.arch_repos && !text.contains("Arch Linux") { + problems.push(Problem { + detail: + "the Arch repositories are enabled but the keyring has no Arch Linux keys" + .into(), + fix: "sudo pacman -S archlinux-keyring && sudo pacman-key --populate archlinux" + .into(), + }); + } + } + Keys::Failed(err) if k.gnupg_dir => problems.push(Problem { + detail: format!("pacman-key cannot read the keyring: {}", err.trim()), + fix: format!("sudo rm -rf /etc/pacman.d/gnupg && {reinit}"), + }), + Keys::Failed(_) | Keys::Unavailable => {} + } + problems +} + +fn keyring_outcome(root_user: bool) -> Outcome { + let conf = fs::read_to_string("/etc/pacman.conf").unwrap_or_default(); + let repos = enabled_repos(&conf); + let arch_repos = repos + .iter() + .any(|r| matches!(r.as_str(), "core" | "extra" | "multilib")); + let artix_keyring_installed = fs::read_dir("/var/lib/pacman/local") + .map(|d| { + d.flatten().any(|e| { + e.file_name() + .to_string_lossy() + .starts_with("artix-keyring-") + }) + }) + .unwrap_or(false); + let listed = if sys::which("pacman-key").is_some() { + sys::run("pacman-key", &["--list-keys"]).ok() + } else { + None + }; + // Without root the keyring may simply be unreadable. + let unreadable = !root_user + && listed + .as_ref() + .is_some_and(|o| !o.ok && o.stderr.contains("ermission")); + let keys = match &listed { + Some(o) if o.ok => Keys::Listed(&o.stdout), + Some(_) if unreadable => Keys::Unavailable, + Some(o) => Keys::Failed(&o.stderr), + None => Keys::Unavailable, + }; + let problems = analyze_keyring(&KeyringState { + gnupg_dir: Path::new("/etc/pacman.d/gnupg").exists(), + artix_keyring_installed, + keys, + arch_repos, + }); + if !problems.is_empty() { + Outcome::Problems(problems) + } else if unreadable { + Outcome::Skipped("cannot read the keyring; run `sudo seg doctor`".into()) + } else if listed.is_none() { + Outcome::Skipped("pacman-key is not installed".into()) + } else { + Outcome::Healthy("pacman's keyring has the Artix keys".into()) + } +} + +fn print_outcome(name: &str, outcome: &Outcome) -> bool { + match outcome { + Outcome::Healthy(msg) => outln!("[ ok ] {name:<10}{msg}"), + Outcome::Skipped(msg) => outln!("[skip] {name:<10}{msg}"), + Outcome::Problems(problems) => { + for p in problems { + outln!("[FAIL] {name:<10}{}", p.detail); + outln!(" {:<10}fix: {}", "", p.fix); + } + } + } + !matches!(outcome, Outcome::Problems(_)) +} + +/// Runs every check. Returns whether the system is healthy. +pub fn run() -> Result { + outln!("{}", bold("seg doctor")); + let root_user = sys::euid().map(|u| u == 0).unwrap_or(false); + + let services = match init::detect_running() { + None => Outcome::Skipped("no supported init system found".into()), + Some(init) => match Svc::new(init, "/").list() { + Ok(list) => services_problems(&list, init), + Err(e) => Outcome::Skipped(e.to_string()), + }, + }; + let dmesg = sys::run("dmesg", &[]) + .ok() + .filter(|o| o.ok) + .map(|o| o.stdout); + let outcomes = [ + ("services", services), + ("firmware", firmware_outcome(dmesg.as_deref())), + ("disk", disk_outcome(&info::df())), + ( + "pacman", + lock_outcome(Path::new("/var/lib/pacman/db.lck"), Path::new("/proc")), + ), + ("keyring", keyring_outcome(root_user)), + ]; + let mut healthy = true; + for (name, outcome) in &outcomes { + healthy &= print_outcome(name, outcome); + } + if healthy { + outln!("\nNo problems found."); + } else { + outln!("\nProblems found: run the fix shown under each one."); + } + Ok(healthy) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::init::Init; + use crate::sys::testutil::TempDir; + + fn svc(name: &str, enabled: bool, state: State) -> Service { + Service { + name: name.into(), + enabled, + state, + } + } + + fn row(mount: &str, avail_kib: u64, use_percent: u8) -> DfRow { + DfRow { + filesystem: "/dev/x".into(), + size_kib: 100_000_000, + used_kib: 0, + avail_kib, + use_percent, + mount: mount.into(), + } + } + + #[test] + fn only_enabled_services_that_are_down_are_failures() { + let list = [ + svc("a", true, State::Started), + svc("b", true, State::Crashed), + svc("c", true, State::Stopped), + svc("d", false, State::Stopped), + svc("e", true, State::Unknown), + ]; + let failed: Vec<_> = failed_services(&list) + .iter() + .map(|s| s.name.as_str()) + .collect(); + assert_eq!(failed, ["b", "c"]); + } + + #[test] + fn the_service_fix_names_the_inits_own_command() { + let list = [svc("cupsd", true, State::Crashed)]; + let Outcome::Problems(p) = services_problems(&list, Init::OpenRc) else { + panic!("no problem") + }; + assert!(p[0].detail.contains("cupsd is enabled but crashed")); + assert!(p[0].fix.contains("rc-service cupsd start")); + assert!(p[0].fix.contains("seg svc disable cupsd")); + let Outcome::Problems(p) = services_problems(&list, Init::Dinit) else { + panic!("no problem") + }; + assert!(p[0].fix.contains("dinitctl start cupsd")); + assert_eq!( + services_problems(&[svc("a", true, State::Started)], Init::OpenRc), + Outcome::Healthy("every enabled service is running".into()) + ); + } + + #[test] + fn firmware_failures_from_dmesg() { + let log = "\ +[ 3.1] r8169 0000:02:00.0: Direct firmware load for rtl_nic/rtl8168h-2.fw failed with error -2 +[ 3.2] r8169 0000:02:00.0: Unable to load firmware rtl_nic/rtl8168h-2.fw (-2) +[ 4.0] iwlwifi 0000:03:00.0: Direct firmware load for iwlwifi-cc-a0-77.ucode failed with error -2 +[ 5.0] bluetooth hci0: Direct firmware load for intel/ibt-0040-0041.sfi failed with error -2 +[ 6.0] foo: firmware: failed to load bar/baz.bin (-2) +[ 6.1] foo: Failed to load firmware \"old/style.fw\" +[ 7.0] amdgpu: Direct firmware load for amdgpu/x.bin failed with error -22 +[ 8.0] usb 1-1: new high-speed USB device +"; + let files = parse_missing_firmware(log); + assert_eq!( + files.into_iter().collect::>(), + [ + "bar/baz.bin", + "intel/ibt-0040-0041.sfi", + "iwlwifi-cc-a0-77.ucode", + "old/style.fw", + "rtl_nic/rtl8168h-2.fw" + ] + ); + assert!(parse_missing_firmware("").is_empty()); + } + + #[test] + fn firmware_outcomes() { + assert!(matches!(firmware_outcome(None), Outcome::Skipped(_))); + assert!(matches!( + firmware_outcome(Some("all fine\n")), + Outcome::Healthy(_) + )); + let Outcome::Problems(p) = firmware_outcome(Some( + "x: Direct firmware load for a/b.fw failed with error -2\n", + )) else { + panic!("no problem") + }; + assert!(p[0].detail.contains("1 firmware file: a/b.fw")); + assert!(p[0].fix.contains("pacman -F usr/lib/firmware/a/b.fw")); + } + + #[test] + fn low_disk_space() { + let rows = [ + row("/", 500 * 1024, 99), // 500 MiB left + row("/home", 400 * 1024 * 1024, 40), // plenty + row("/var", 3 * 1024 * 1024, 93), // 3 GiB left at 93% + row("/boot", 10 * 1024, 95), // 10 MiB: nearly nothing + row("/boot/efi", 90 * 1024, 70), // small by design, fine + row("/mnt/data", 1, 100), // not watched + row("/usr", 50 * 1024 * 1024, 92), // 92% but 50 GiB left + ]; + let low: Vec<_> = low_space(&rows).iter().map(|r| r.mount.as_str()).collect(); + assert_eq!(low, ["/", "/var", "/boot"]); + let Outcome::Problems(p) = disk_outcome(&rows) else { + panic!("no problem") + }; + assert!(p[0].detail.contains("/ is 99% full")); + assert!(p[0].fix.contains("seg clean")); + assert!(p[2].fix.contains("old kernels")); + assert!(matches!(disk_outcome(&[]), Outcome::Skipped(_))); + } + + #[test] + fn a_lock_is_stale_only_without_a_running_pacman() { + assert!(lock_is_stale(true, false)); + assert!(!lock_is_stale(true, true)); + assert!(!lock_is_stale(false, false)); + } + + #[test] + fn lock_check_looks_for_a_pacman_process() { + let t = TempDir::new(); + let lock = t.put("db.lck", ""); + t.put("proc/1/comm", "init\n"); + t.put("proc/42/comm", "bash\n"); + let Outcome::Problems(p) = lock_outcome(&lock, &t.path().join("proc")) else { + panic!("no problem") + }; + assert!(p[0].fix.starts_with("sudo rm ")); + t.put("proc/43/comm", "pacman\n"); + assert!(matches!( + lock_outcome(&lock, &t.path().join("proc")), + Outcome::Healthy(_) + )); + fs::remove_file(&lock).unwrap(); + assert_eq!( + lock_outcome(&lock, &t.path().join("proc")), + Outcome::Healthy("pacman is not locked".into()) + ); + } + + #[test] + fn repos_of_pacman_conf() { + let conf = "[options]\nColor\n[system]\nInclude = x\n# [extra]\n[world]\n[lib32]\n"; + assert_eq!(enabled_repos(conf), ["system", "world", "lib32"]); + assert_eq!(enabled_repos("[options]\n"), Vec::::new()); + } + + #[test] + fn keyring_analysis() { + let healthy = KeyringState { + gnupg_dir: true, + artix_keyring_installed: true, + keys: Keys::Listed("uid [ full ] Artix Linux Master Key"), + arch_repos: false, + }; + assert!(analyze_keyring(&healthy).is_empty()); + + let empty = KeyringState { + gnupg_dir: false, + artix_keyring_installed: false, + keys: Keys::Unavailable, + arch_repos: false, + }; + let p = analyze_keyring(&empty); + assert_eq!(p.len(), 2); + assert!(p[0].fix.contains("pacman-key --init")); + assert!(p[1].fix.contains("pacman -Sy artix-keyring")); + + let unpopulated = KeyringState { + keys: Keys::Listed("pubring.kbx\n-----\n"), + ..healthy + }; + let p = analyze_keyring(&unpopulated); + assert_eq!(p.len(), 1); + assert_eq!(p[0].fix, "sudo pacman-key --populate artix"); + + let arch = KeyringState { + gnupg_dir: true, + artix_keyring_installed: true, + keys: Keys::Listed("Artix"), + arch_repos: true, + }; + let p = analyze_keyring(&arch); + assert_eq!(p.len(), 1); + assert!(p[0].fix.contains("archlinux-keyring")); + + let broken = KeyringState { + gnupg_dir: true, + artix_keyring_installed: true, + keys: Keys::Failed("gpg: keyblock resource: No such file\n"), + arch_repos: false, + }; + let p = analyze_keyring(&broken); + assert!( + p[0].detail + .contains("cannot read the keyring: gpg: keyblock resource") + ); + assert!(p[0].fix.contains("rm -rf /etc/pacman.d/gnupg")); + } +} diff --git a/tools/seg/segctl/src/error.rs b/tools/seg/segctl/src/error.rs new file mode 100644 index 0000000..b29f49e --- /dev/null +++ b/tools/seg/segctl/src/error.rs @@ -0,0 +1,109 @@ +//! One error type for the whole tool, so `main` can print it and pick the +//! exit status. No panics on user-facing paths: everything returns this. + +use std::fmt; +use std::io; + +#[derive(Debug)] +pub enum Error { + /// Wrong command line; the exit status is 2. + Usage(String), + /// The command writes to the system and must run as root. + NeedRoot(String), + Io { + context: String, + source: io::Error, + }, + /// A program could not be started, or failed. + Command { + program: String, + detail: String, + }, + /// Something this system does not have (no init found, no zram module). + Unsupported(String), + /// Anything else, already worded for the user. + Other(String), +} + +pub type Result = std::result::Result; + +impl Error { + pub fn io(context: impl Into, source: io::Error) -> Self { + Error::Io { + context: context.into(), + source, + } + } + + pub fn other(message: impl Into) -> Self { + Error::Other(message.into()) + } + + pub fn exit_code(&self) -> u8 { + match self { + Error::Usage(_) => 2, + _ => 1, + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Usage(m) => write!(f, "{m}\nTry `seg --help`."), + Error::NeedRoot(what) => write!( + f, + "{what} needs root. Run it again with sudo: `sudo seg ...`." + ), + Error::Io { context, source } => write!(f, "{context}: {source}"), + Error::Command { program, detail } => write!(f, "`{program}`: {detail}"), + Error::Unsupported(m) | Error::Other(m) => f.write_str(m), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(e: lexopt::Error) -> Self { + Error::Usage(e.to_string()) + } +} + +/// `.context("reading /proc/meminfo")?` on io results. +pub trait Context { + fn context(self, what: impl Into) -> Result; +} + +impl Context for io::Result { + fn context(self, what: impl Into) -> Result { + self.map_err(|e| Error::io(what, e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn usage_errors_exit_2_and_others_1() { + assert_eq!(Error::Usage("x".into()).exit_code(), 2); + assert_eq!(Error::NeedRoot("tune".into()).exit_code(), 1); + assert_eq!(Error::other("x").exit_code(), 1); + } + + #[test] + fn root_error_names_the_command_and_the_fix() { + let text = Error::NeedRoot("`seg tune performance`".into()).to_string(); + assert!(text.contains("`seg tune performance` needs root")); + assert!(text.contains("sudo")); + } + + #[test] + fn io_errors_keep_their_context() { + let e = Err::<(), _>(io::Error::from(io::ErrorKind::NotFound)) + .context("reading /proc/meminfo") + .unwrap_err(); + assert!(e.to_string().starts_with("reading /proc/meminfo: ")); + } +} diff --git a/tools/seg/segctl/src/info.rs b/tools/seg/segctl/src/info.rs new file mode 100644 index 0000000..a04cd9e --- /dev/null +++ b/tools/seg/segctl/src/info.rs @@ -0,0 +1,213 @@ +//! `seg info`: a fast fetch-style summary. Reads /proc and /etc directly; +//! the only program it starts is `df`. + +use std::fs; +use std::path::Path; + +use crate::error::Result; +use crate::init; +use crate::sys::{self, bold, human_bytes, human_duration}; + +/// `PRETTY_NAME` (else `NAME`) from os-release text. +pub fn parse_os_release(text: &str) -> Option { + let value = |key: &str| { + text.lines().find_map(|l| { + let v = l.strip_prefix(key)?.strip_prefix('=')?; + Some(v.trim().trim_matches('"').trim_matches('\'').to_string()) + }) + }; + value("PRETTY_NAME") + .or_else(|| value("NAME")) + .filter(|s| !s.is_empty()) +} + +/// Seconds up, from /proc/uptime (`12345.67 54321.00`). +pub fn parse_uptime(text: &str) -> Option { + let secs: f64 = text.split_whitespace().next()?.parse().ok()?; + (secs >= 0.0).then_some(secs as u64) +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Memory { + pub total_kib: u64, + pub available_kib: u64, +} + +impl Memory { + pub fn used_kib(&self) -> u64 { + self.total_kib.saturating_sub(self.available_kib) + } +} + +pub fn parse_meminfo(text: &str) -> Option { + let field = |key: &str| -> Option { + text.lines() + .find(|l| l.starts_with(key))? + .split_whitespace() + .nth(1)? + .parse() + .ok() + }; + Some(Memory { + total_kib: field("MemTotal:")?, + available_kib: field("MemAvailable:").or_else(|| field("MemFree:"))?, + }) +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct DfRow { + pub filesystem: String, + pub size_kib: u64, + pub used_kib: u64, + pub avail_kib: u64, + pub use_percent: u8, + pub mount: String, +} + +/// `df -Pk` output (POSIX format: one line per file system). +pub fn parse_df(text: &str) -> Vec { + text.lines() + .skip(1) + .filter_map(|line| { + let mut f = line.split_whitespace(); + let filesystem = f.next()?.to_string(); + let size_kib = f.next()?.parse().ok()?; + let used_kib = f.next()?.parse().ok()?; + let avail_kib = f.next()?.parse().ok()?; + let use_percent = f.next()?.trim_end_matches('%').parse().ok()?; + let mount = f.collect::>().join(" "); + (!mount.is_empty()).then_some(DfRow { + filesystem, + size_kib, + used_kib, + avail_kib, + use_percent, + mount, + }) + }) + .collect() +} + +pub fn df() -> Vec { + sys::run("df", &["-Pk"]) + .map(|o| parse_df(&o.stdout)) + .unwrap_or_default() +} + +/// Installed packages: one directory per package in pacman's local database. +pub fn count_packages(local_db: &Path) -> Option { + let entries = fs::read_dir(local_db).ok()?; + Some(entries.flatten().filter(|e| e.path().is_dir()).count()) +} + +pub fn run() -> Result<()> { + let distro = sys::read_trimmed("/etc/os-release") + .or_else(|| sys::read_trimmed("/usr/lib/os-release")) + .and_then(|t| parse_os_release(&t)) + .unwrap_or_else(|| "Linux".into()); + let kernel = sys::read_trimmed("/proc/sys/kernel/osrelease").unwrap_or_else(|| "?".into()); + let init = init::detect_running().map_or("unknown", |i| i.name()); + let uptime = sys::read_trimmed("/proc/uptime") + .and_then(|t| parse_uptime(&t)) + .map_or_else(|| "?".into(), human_duration); + let memory = sys::read_trimmed("/proc/meminfo") + .and_then(|t| parse_meminfo(&t)) + .map_or_else( + || "?".into(), + |m| { + let pct = m.used_kib() * 100 / m.total_kib.max(1); + format!( + "{} / {} ({pct}%)", + human_bytes(m.used_kib() * 1024), + human_bytes(m.total_kib * 1024) + ) + }, + ); + let packages = count_packages(Path::new("/var/lib/pacman/local")) + .map_or_else(|| "?".into(), |n| format!("{n} (pacman)")); + let disk = df().into_iter().find(|r| r.mount == "/").map_or_else( + || "?".into(), + |r| { + format!( + "{} / {} ({}%)", + human_bytes(r.used_kib * 1024), + human_bytes(r.size_kib * 1024), + r.use_percent + ) + }, + ); + + outln!("{}", bold(&distro)); + for (label, value) in [ + ("Kernel", kernel), + ("Init", init.to_string()), + ("Uptime", uptime), + ("Memory", memory), + ("Packages", packages), + ("Disk /", disk), + ] { + outln!(" {label:<9}{value}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + #[test] + fn os_release_prefers_the_pretty_name() { + let t = "NAME=\"SegLinux\"\nPRETTY_NAME=\"SegLinux (Artix)\"\nID=seglinux\n"; + assert_eq!(parse_os_release(t), Some("SegLinux (Artix)".into())); + assert_eq!( + parse_os_release("NAME='Artix Linux'\n"), + Some("Artix Linux".into()) + ); + assert_eq!(parse_os_release("ID=x\n"), None); + assert_eq!(parse_os_release("PRETTY_NAME=\"\"\n"), None); + } + + #[test] + fn uptime_and_memory() { + assert_eq!(parse_uptime("8100.52 30000.10\n"), Some(8100)); + assert_eq!(parse_uptime(""), None); + assert_eq!(parse_uptime("-1 2"), None); + + let m = parse_meminfo( + "MemTotal: 16000000 kB\nMemFree: 1000000 kB\nMemAvailable: 12000000 kB\n", + ) + .unwrap(); + assert_eq!(m.used_kib(), 4_000_000); + // Old kernels have no MemAvailable. + let m = parse_meminfo("MemTotal: 100 kB\nMemFree: 40 kB\n").unwrap(); + assert_eq!(m.used_kib(), 60); + assert_eq!(parse_meminfo("MemTotal: 100 kB\n"), None); + } + + #[test] + fn df_rows() { + let text = "Filesystem 1024-blocks Used Available Capacity Mounted on\n\ + /dev/nvme0n1p2 244813824 21474836 210000000 9% /\n\ + tmpfs 8000000 0 8000000 0% /dev/shm\n\ + /dev/sdb1 1000000 500000 500000 50% /mnt/My Disk\n\ + garbage line\n"; + let rows = parse_df(text); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].mount, "/"); + assert_eq!(rows[0].use_percent, 9); + assert_eq!(rows[0].avail_kib, 210_000_000); + assert_eq!(rows[2].mount, "/mnt/My Disk"); + assert!(parse_df("").is_empty()); + } + + #[test] + fn package_count_counts_directories() { + let t = TempDir::new(); + t.put("local/bash-5.2-1/desc", ""); + t.put("local/glibc-2.40-1/desc", ""); + t.put("local/ALPM_DB_VERSION", "9\n"); + assert_eq!(count_packages(&t.path().join("local")), Some(2)); + assert_eq!(count_packages(&t.path().join("missing")), None); + } +} diff --git a/tools/seg/segctl/src/init.rs b/tools/seg/segctl/src/init.rs new file mode 100644 index 0000000..6cc35ac --- /dev/null +++ b/tools/seg/segctl/src/init.rs @@ -0,0 +1,138 @@ +//! Which init system is running. Artix offers four; `seg svc` speaks all of +//! them. + +use std::fmt; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Init { + OpenRc, + Runit, + Dinit, + S6, +} + +impl Init { + pub fn name(self) -> &'static str { + match self { + Init::OpenRc => "openrc", + Init::Runit => "runit", + Init::Dinit => "dinit", + Init::S6 => "s6", + } + } + + /// The name PID 1 gives itself in /proc/1/comm. + fn from_pid1(comm: &str) -> Option { + match comm.trim() { + "openrc-init" => Some(Init::OpenRc), + "runit" | "runit-init" => Some(Init::Runit), + "dinit" => Some(Init::Dinit), + "s6-svscan" | "s6-linux-init" | "s6-linux-init-maker" => Some(Init::S6), + _ => None, + } + } +} + +impl fmt::Display for Init { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// Detect the init system of the system under `root` (`/` for the running +/// one). In order of certainty: +/// +/// 1. what PID 1 is (`pid1_comm`, the contents of /proc/1/comm); +/// 2. state the running init leaves in /run; +/// 3. what is installed, for an offline root or a container whose PID 1 is +/// something else. +pub fn detect(root: &Path, pid1_comm: Option<&str>) -> Option { + if let Some(init) = pid1_comm.and_then(Init::from_pid1) { + return Some(init); + } + let has = |rel: &str| root.join(rel).exists(); + const RUNNING: [(&str, Init); 4] = [ + ("run/openrc", Init::OpenRc), + ("run/runit", Init::Runit), + ("run/dinitctl", Init::Dinit), + ("run/s6-rc", Init::S6), + ]; + if let Some((_, init)) = RUNNING.iter().find(|(path, _)| has(path)) { + return Some(*init); + } + const INSTALLED: [(&str, Init); 7] = [ + ("usr/bin/openrc-init", Init::OpenRc), + ("usr/bin/openrc", Init::OpenRc), + ("usr/bin/dinit", Init::Dinit), + ("usr/bin/runit", Init::Runit), + ("usr/bin/runsvdir", Init::Runit), + ("usr/bin/s6-svscan", Init::S6), + ("usr/bin/s6-rc", Init::S6), + ]; + INSTALLED + .iter() + .find(|(path, _)| has(path)) + .map(|(_, init)| *init) +} + +/// Detect for the running system. +pub fn detect_running() -> Option { + let comm = std::fs::read_to_string("/proc/1/comm").ok(); + detect(Path::new("/"), comm.as_deref()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + #[test] + fn pid1_decides_first() { + let t = TempDir::new(); + // Even if another init's files are around. + t.put("usr/bin/openrc", ""); + assert_eq!(detect(t.path(), Some("dinit\n")), Some(Init::Dinit)); + assert_eq!(detect(t.path(), Some("runit")), Some(Init::Runit)); + assert_eq!(detect(t.path(), Some("openrc-init")), Some(Init::OpenRc)); + assert_eq!(detect(t.path(), Some("s6-svscan")), Some(Init::S6)); + } + + #[test] + fn a_generic_pid1_falls_back_to_the_state_in_run() { + let t = TempDir::new(); + t.put("run/openrc/softlevel", "default"); + assert_eq!(detect(t.path(), Some("init")), Some(Init::OpenRc)); + let t = TempDir::new(); + t.put("run/dinitctl", ""); + assert_eq!(detect(t.path(), None), Some(Init::Dinit)); + let t = TempDir::new(); + t.put("run/runit/stopit", ""); + assert_eq!(detect(t.path(), None), Some(Init::Runit)); + } + + #[test] + fn installed_binaries_are_the_last_resort() { + let t = TempDir::new(); + assert_eq!(detect(t.path(), Some("bash")), None); + t.put("usr/bin/s6-svscan", ""); + assert_eq!(detect(t.path(), Some("bash")), Some(Init::S6)); + t.put("usr/bin/openrc", ""); + // The first match in the list wins: OpenRC before s6. + assert_eq!(detect(t.path(), None), Some(Init::OpenRc)); + } + + #[test] + fn running_state_beats_installed_binaries() { + let t = TempDir::new(); + t.put("usr/bin/openrc", ""); + t.put("run/dinitctl", ""); + assert_eq!(detect(t.path(), None), Some(Init::Dinit)); + } + + #[test] + fn names() { + assert_eq!(Init::OpenRc.to_string(), "openrc"); + assert_eq!(Init::S6.name(), "s6"); + } +} diff --git a/tools/seg/segctl/src/main.rs b/tools/seg/segctl/src/main.rs new file mode 100644 index 0000000..e2fae1c --- /dev/null +++ b/tools/seg/segctl/src/main.rs @@ -0,0 +1,234 @@ +//! seg: system information, services, cleaning, tuning, zram swap and +//! health checks for SegLinux, over runit, OpenRC, dinit and s6. + +// No unwrap or panic on paths a user can reach: errors are values. +#![cfg_attr( + not(test), + deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + ) +)] + +/// `print!` and `println!` that end quietly when the reader goes away +/// (`seg clean | head`) instead of panicking on the broken pipe. +macro_rules! out { + ($($arg:tt)*) => { $crate::sys::emit(format_args!($($arg)*), false) }; +} +macro_rules! outln { + ($($arg:tt)*) => { $crate::sys::emit(format_args!($($arg)*), true) }; +} + +mod clean; +mod doctor; +mod error; +mod info; +mod init; +mod svc; +mod sys; +mod tune; +mod vercmp; +mod zram; + +use std::process::ExitCode; + +use error::{Error, Result}; + +const HELP: &str = "\ +seg - look after a SegLinux system + +Usage: seg [options] + +Commands: + info distro, kernel, init, uptime, memory, packages, disk + svc list|enable|disable|start|stop [name] + services, on whichever init is running + clean [--apply] orphans, old cached packages, old logs (dry run by default) + tune light|balanced|performance|apply|status + memory, CPU and disk settings + zram on|off|status compressed swap in memory + doctor look for problems and print their fixes + +Options: + -h, --help show this help (also: seg --help) + -V, --version show the version +"; + +const SVC_HELP: &str = "\ +Usage: seg svc [name] + + list [--enabled] installed services: enabled at boot, and running or not + enable start at boot + disable do not start at boot + start start now + stop stop now + +Works on OpenRC, runit, dinit and s6 (detected). enable, disable, start and +stop need root; list does not. + +--offline pick the init from what is installed here, not from the running + PID 1: for package scripts that run in a chroot +"; + +fn main() -> ExitCode { + match run() { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::from(1), + Err(e) => { + sys::emit_err(format_args!("seg: {e}")); + ExitCode::from(e.exit_code()) + } + } +} + +/// Returns whether everything was fine (`doctor` reports problems this way). +fn run() -> Result { + use lexopt::prelude::*; + let mut parser = lexopt::Parser::from_env(); + let command = match parser.next()? { + None => { + out!("{HELP}"); + return Ok(true); + } + Some(Short('h') | Long("help")) => { + out!("{HELP}"); + return Ok(true); + } + Some(Short('V') | Long("version")) => { + outln!("seg {}", env!("CARGO_PKG_VERSION")); + return Ok(true); + } + Some(Value(v)) => v.string()?, + Some(other) => return Err(other.unexpected().into()), + }; + match command.as_str() { + "info" => { + no_more_args(&mut parser, "info")?; + info::run()?; + } + "svc" => svc_command(&mut parser)?, + "clean" => clean::run(clean::parse_args(&mut parser)?)?, + "tune" => tune::run(tune::parse_args(&mut parser)?)?, + "zram" => zram::run(zram::parse_args(&mut parser)?)?, + "doctor" => { + no_more_args(&mut parser, "doctor")?; + return doctor::run(); + } + other => { + return Err(Error::Usage(format!("unknown command `{other}`"))); + } + } + Ok(true) +} + +/// Commands without options of their own: `--help` or nothing. +fn no_more_args(parser: &mut lexopt::Parser, name: &str) -> Result<()> { + use lexopt::prelude::*; + match parser.next()? { + None => Ok(()), + Some(Short('h') | Long("help")) => { + match name { + "doctor" => out!("{}", doctor::HELP), + _ => outln!("Usage: seg {name}\n\nPrints a short summary of this system."), + } + std::process::exit(0); + } + Some(other) => Err(other.unexpected().into()), + } +} + +fn svc_command(parser: &mut lexopt::Parser) -> Result<()> { + use lexopt::prelude::*; + let mut action: Option = None; + let mut name: Option = None; + let mut only_enabled = false; + let mut offline = false; + while let Some(arg) = parser.next()? { + match arg { + Short('h') | Long("help") => { + out!("{SVC_HELP}"); + return Ok(()); + } + Long("enabled") => only_enabled = true, + Long("offline") => offline = true, + Value(v) if action.is_none() => action = Some(v.string()?), + Value(v) if name.is_none() => name = Some(v.string()?), + other => return Err(other.unexpected().into()), + } + } + let action = action.ok_or_else(|| { + Error::Usage("seg svc needs a command (list, enable, disable, start or stop)".into()) + })?; + let detected = if offline { + init::detect(std::path::Path::new("/"), None) + } else { + init::detect_running() + }; + let init = detected.ok_or_else(|| { + Error::Unsupported("no supported init system found (OpenRC, runit, dinit or s6)".into()) + })?; + let svc = svc::Svc::new(init, "/"); + let need_name = || { + name.clone() + .ok_or_else(|| Error::Usage(format!("seg svc {action} needs a service name"))) + }; + match action.as_str() { + "list" => { + outln!("{:<28}{:<9}STATE ({init})", "SERVICE", "BOOT"); + for s in svc + .list()? + .into_iter() + .filter(|s| !only_enabled || s.enabled) + { + outln!( + "{:<28}{:<9}{}", + s.name, + if s.enabled { "enabled" } else { "-" }, + s.state.label() + ); + } + } + "enable" => { + sys::require_root("`seg svc enable`")?; + let name = need_name()?; + match svc.enable(&name)? { + svc::Change::Enabled => { + outln!("{name} will start at boot. Start it now with: seg svc start {name}"); + } + _ => outln!("{name} already starts at boot"), + } + } + "disable" => { + sys::require_root("`seg svc disable`")?; + let name = need_name()?; + match svc.disable(&name)? { + svc::Change::Disabled => { + outln!("{name} will not start at boot. Stop it now with: seg svc stop {name}"); + } + _ => outln!("{name} already does not start at boot"), + } + } + "start" | "stop" => { + sys::require_root(if action == "start" { + "`seg svc start`" + } else { + "`seg svc stop`" + })?; + let name = need_name()?; + let act = if action == "start" { + svc::Action::Start + } else { + svc::Action::Stop + }; + svc.control(act, &name)?; + } + other => { + return Err(Error::Usage(format!( + "unknown svc command `{other}` (list, enable, disable, start or stop)" + ))); + } + } + Ok(()) +} diff --git a/tools/seg/segctl/src/svc.rs b/tools/seg/segctl/src/svc.rs new file mode 100644 index 0000000..21974bb --- /dev/null +++ b/tools/seg/segctl/src/svc.rs @@ -0,0 +1,735 @@ +//! `seg svc`: one interface over the services of OpenRC, runit, dinit and s6. +//! +//! Enabling and disabling only touch the files each init reads at boot (the +//! symlinks `rc-update`, `ln -s ... /etc/runit/runsvdir/default` and +//! `dinitctl enable` make), so they work on a running system, in a chroot and +//! on an offline root alike. Starting and stopping ask the running init. + +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; + +use crate::error::{Context, Error, Result}; +use crate::init::Init; +use crate::sys; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum State { + Started, + Stopped, + Crashed, + Unknown, +} + +impl State { + pub fn label(self) -> &'static str { + match self { + State::Started => "started", + State::Stopped => "stopped", + State::Crashed => "crashed", + State::Unknown => "-", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Service { + pub name: String, + pub enabled: bool, + pub state: State, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Start, + Stop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Change { + Enabled, + AlreadyEnabled, + Disabled, + AlreadyDisabled, +} + +/// Service names become file names under /etc: no paths, no hidden files. +pub fn valid_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('.') + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.@+".contains(c)) +} + +/// OpenRC services that belong in the `boot` runlevel rather than `default`. +fn openrc_runlevel(name: &str) -> &'static str { + const BOOT: &[&str] = &[ + "elogind", + "udev", + "udev-trigger", + "binfmt", + "sysctl", + "modules", + "hwclock", + "keymaps", + "hostname", + "localmount", + "lvm", + "dmcrypt", + "mdraid", + "haveged", + ]; + if BOOT.contains(&name) { + "boot" + } else { + "default" + } +} + +/// The services of one init system under `root` (`/` for the running one). +pub struct Svc { + init: Init, + root: PathBuf, +} + +impl Svc { + pub fn new(init: Init, root: impl Into) -> Self { + Svc { + init, + root: root.into(), + } + } + + fn p(&self, rel: &str) -> PathBuf { + self.root.join(rel.trim_start_matches('/')) + } + + fn live(&self) -> bool { + self.root == Path::new("/") + } + + /// Where the installed definition of `name` lives. + fn definition(&self, name: &str) -> String { + match self.init { + Init::OpenRc => format!("/etc/init.d/{name}"), + Init::Runit => format!("/etc/runit/sv/{name}"), + Init::Dinit => format!("/etc/dinit.d/{name}"), + Init::S6 => format!("/etc/s6/sv/{name}"), + } + } + + pub fn exists(&self, name: &str) -> bool { + self.p(&self.definition(name)).exists() + } + + fn check(&self, name: &str) -> Result<()> { + if !valid_name(name) { + return Err(Error::Usage(format!("`{name}` is not a service name"))); + } + if !self.exists(name) { + return Err(Error::other(format!( + "no service `{name}` for {} (expected {}); `seg svc list` shows the installed ones", + self.init, + self.definition(name) + ))); + } + Ok(()) + } + + /// Symlinks (or marker files) that make `name` start at boot. + fn boot_links(&self, name: &str) -> Vec<(String, Option)> { + match self.init { + Init::OpenRc => vec![( + format!("/etc/runlevels/{}/{name}", openrc_runlevel(name)), + Some(format!("/etc/init.d/{name}")), + )], + Init::Runit => vec![( + format!("/etc/runit/runsvdir/default/{name}"), + Some(format!("/etc/runit/sv/{name}")), + )], + Init::Dinit => vec![( + format!("/etc/dinit.d/boot.d/{name}"), + Some(format!("../{name}")), + )], + // s6-rc bundles are compiled databases: the source of the + // `default` bundle lists its contents as empty files. + Init::S6 => vec![(format!("/etc/s6/adminsv/default/contents.d/{name}"), None)], + } + } + + /// Every place `name` may be enabled in (OpenRC has several runlevels). + fn enabled_paths(&self, name: &str) -> Vec { + match self.init { + Init::OpenRc => { + let Ok(levels) = fs::read_dir(self.p("/etc/runlevels")) else { + return Vec::new(); + }; + levels + .flatten() + .map(|l| l.path().join(name)) + .filter(|p| p.symlink_metadata().is_ok()) + .collect() + } + _ => self + .boot_links(name) + .into_iter() + .map(|(link, _)| self.p(&link)) + .filter(|p| p.symlink_metadata().is_ok()) + .collect(), + } + } + + pub fn enable(&self, name: &str) -> Result { + self.check(name)?; + if !self.enabled_paths(name).is_empty() { + return Ok(Change::AlreadyEnabled); + } + for (link, target) in self.boot_links(name) { + let link = self.p(&link); + if let Some(dir) = link.parent() { + fs::create_dir_all(dir).context(format!("creating {}", dir.display()))?; + } + match target { + Some(target) => symlink(&target, &link) + .context(format!("linking {} to {target}", link.display()))?, + None => fs::write(&link, "").context(format!("creating {}", link.display()))?, + } + } + if self.init == Init::S6 && self.live() && sys::which("s6-db-reload").is_some() { + sys::run_inherit("s6-db-reload", &[])?; + } + Ok(Change::Enabled) + } + + pub fn disable(&self, name: &str) -> Result { + if !valid_name(name) { + return Err(Error::Usage(format!("`{name}` is not a service name"))); + } + let paths = self.enabled_paths(name); + if paths.is_empty() { + return Ok(Change::AlreadyDisabled); + } + for path in paths { + fs::remove_file(&path).context(format!("removing {}", path.display()))?; + } + if self.init == Init::S6 && self.live() && sys::which("s6-db-reload").is_some() { + sys::run_inherit("s6-db-reload", &[])?; + } + Ok(Change::Disabled) + } + + /// All installed services with whether they start at boot and, when the + /// init can be asked, whether they run. + pub fn list(&self) -> Result> { + let (available, enabled) = self.available_and_enabled(); + let states = if self.live() { + self.states(&enabled) + } else { + HashMap::new() + }; + Ok(available + .into_iter() + .map(|name| Service { + enabled: enabled.contains(&name), + state: states.get(&name).copied().unwrap_or(State::Unknown), + name, + }) + .collect()) + } + + fn available_and_enabled(&self) -> (BTreeSet, BTreeSet) { + let names = |dir: &str, want_dirs: bool| -> BTreeSet { + let Ok(entries) = fs::read_dir(self.p(dir)) else { + return BTreeSet::new(); + }; + entries + .flatten() + .filter(|e| { + let is_dir = e.path().is_dir(); + is_dir == want_dirs + }) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| valid_name(n)) + .collect() + }; + match self.init { + Init::OpenRc => { + let mut enabled = BTreeSet::new(); + if let Ok(levels) = fs::read_dir(self.p("/etc/runlevels")) { + for level in levels.flatten() { + if let Ok(entries) = fs::read_dir(level.path()) { + enabled.extend( + entries + .flatten() + .filter_map(|e| e.file_name().into_string().ok()), + ); + } + } + } + (names("/etc/init.d", false), enabled) + } + Init::Runit => { + let enabled = fs::read_dir(self.p("/etc/runit/runsvdir/default")) + .map(|d| { + d.flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .collect() + }) + .unwrap_or_default(); + (names("/etc/runit/sv", true), enabled) + } + Init::Dinit => { + let enabled = fs::read_dir(self.p("/etc/dinit.d/boot.d")) + .map(|d| { + d.flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .collect() + }) + .unwrap_or_default(); + (names("/etc/dinit.d", false), enabled) + } + Init::S6 => { + let enabled = fs::read_dir(self.p("/etc/s6/adminsv/default/contents.d")) + .map(|d| { + d.flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .collect() + }) + .unwrap_or_default(); + (names("/etc/s6/sv", true), enabled) + } + } + } + + /// Ask the running init what runs. Best effort: a missing tool means + /// "unknown", not an error. + fn states(&self, enabled: &BTreeSet) -> HashMap { + let ask = |program: &str, args: &[&str]| -> Option { + sys::which(program)?; + let out = sys::run(program, args).ok()?; + // rc-status and sv exit non-zero when something is down, with + // the list on stdout all the same. + (!out.stdout.is_empty()).then_some(out.stdout) + }; + match self.init { + Init::OpenRc => ask("rc-status", &["-s"]) + .map(|t| parse_rc_status(&t)) + .unwrap_or_default(), + Init::Runit => { + let dirs: Vec = enabled + .iter() + .map(|n| format!("/etc/runit/runsvdir/default/{n}")) + .collect(); + if dirs.is_empty() { + return HashMap::new(); + } + let mut args = vec!["status"]; + args.extend(dirs.iter().map(String::as_str)); + ask("sv", &args) + .map(|t| parse_sv_status(&t)) + .unwrap_or_default() + } + Init::Dinit => ask("dinitctl", &["list"]) + .map(|t| parse_dinit_list(&t)) + .unwrap_or_default(), + Init::S6 => { + let Some(active) = ask("s6-rc", &["-a", "list"]) else { + return HashMap::new(); + }; + let active: BTreeSet<&str> = active.lines().map(str::trim).collect(); + let (available, _) = self.available_and_enabled(); + available + .into_iter() + .map(|n| { + let state = if active.contains(n.as_str()) { + State::Started + } else { + State::Stopped + }; + (n, state) + }) + .collect() + } + } + } + + /// The command that starts or stops `name` on the running init. + pub fn control_command(&self, action: Action, name: &str) -> Result<(String, Vec)> { + self.check(name)?; + let verb = match action { + Action::Start => "start", + Action::Stop => "stop", + }; + Ok(match self.init { + Init::OpenRc => ("rc-service".into(), vec![name.into(), verb.into()]), + Init::Runit => { + let dir = self.p(&format!("/etc/runit/runsvdir/default/{name}")); + if dir.symlink_metadata().is_err() { + return Err(Error::other(format!( + "`{name}` is not enabled, so runit does not supervise it; run `seg svc enable {name}` first" + ))); + } + ("sv".into(), vec![verb.into(), dir.display().to_string()]) + } + Init::Dinit => ("dinitctl".into(), vec![verb.into(), name.into()]), + Init::S6 => ( + "s6-rc".into(), + vec![ + match action { + Action::Start => "-u", + Action::Stop => "-d", + } + .into(), + "change".into(), + name.into(), + ], + ), + }) + } + + pub fn control(&self, action: Action, name: &str) -> Result<()> { + let (program, args) = self.control_command(action, name)?; + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + sys::run_inherit(&program, &args) + } +} + +/// `rc-status -s`: ` sshd [ started ]`, ` foo [ started 00:01:02 (0) ]`. +pub fn parse_rc_status(text: &str) -> HashMap { + text.lines() + .filter_map(|line| { + let (name, rest) = line.split_once('[')?; + let name = name.trim(); + if name.is_empty() || name.contains(char::is_whitespace) { + return None; + } + let word = rest.split(']').next()?.split_whitespace().next()?; + let state = match word { + "started" => State::Started, + "stopped" | "inactive" | "stopping" => State::Stopped, + "crashed" => State::Crashed, + _ => State::Unknown, + }; + Some((name.to_string(), state)) + }) + .collect() +} + +/// `sv status`: `run: /etc/runit/runsvdir/default/sshd: (pid 12) 5s; ...`. +pub fn parse_sv_status(text: &str) -> HashMap { + text.lines() + .filter_map(|line| { + let (word, rest) = line.split_once(": ")?; + let path = rest.split(':').next()?; + let name = path.rsplit('/').next()?.trim(); + if name.is_empty() { + return None; + } + let state = match word { + "run" => State::Started, + "down" | "finish" | "warning" => State::Stopped, + "fail" => State::Crashed, + _ => State::Unknown, + }; + Some((name.to_string(), state)) + }) + .collect() +} + +/// `dinitctl list`: `[{+} ] NetworkManager (pid: 450)`. The first +/// three characters after the bracket are the service's state; a `+` in +/// them means started. +pub fn parse_dinit_list(text: &str) -> HashMap { + text.lines() + .filter_map(|line| { + let line = line.trim(); + let group = line.strip_prefix('[')?.get(..3)?; + let tail = line.get(4..)?; + let rest = tail.get(tail.find(']')? + 1..)?; + let name = rest.split_whitespace().next()?; + let state = if group.contains('+') { + State::Started + } else { + State::Stopped + }; + Some((name.to_string(), state)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + fn link_target(root: &Path, rel: &str) -> PathBuf { + fs::read_link(root.join(rel)).unwrap() + } + + #[test] + fn service_names_are_file_names() { + for good in [ + "sshd", + "NetworkManager", + "seg-zram", + "getty@tty1", + "a.b_c+d", + ] { + assert!(valid_name(good), "{good}"); + } + for bad in ["", ".hidden", "../etc/passwd", "a/b", "a b", "a;b", "$(x)"] { + assert!(!valid_name(bad), "{bad}"); + } + } + + #[test] + fn openrc_enable_disable_and_runlevels() { + let t = TempDir::new(); + t.put("etc/init.d/sshd", ""); + t.put("etc/init.d/sysctl", ""); + let svc = Svc::new(Init::OpenRc, t.path()); + assert_eq!(svc.enable("sshd").unwrap(), Change::Enabled); + assert_eq!(svc.enable("sshd").unwrap(), Change::AlreadyEnabled); + assert_eq!( + link_target(t.path(), "etc/runlevels/default/sshd"), + Path::new("/etc/init.d/sshd") + ); + svc.enable("sysctl").unwrap(); + assert!( + t.path() + .join("etc/runlevels/boot/sysctl") + .symlink_metadata() + .is_ok() + ); + assert_eq!(svc.disable("sshd").unwrap(), Change::Disabled); + assert_eq!(svc.disable("sshd").unwrap(), Change::AlreadyDisabled); + assert!(!t.path().join("etc/runlevels/default/sshd").exists()); + } + + #[test] + fn openrc_disable_removes_every_runlevel() { + let t = TempDir::new(); + t.put("etc/init.d/foo", ""); + let svc = Svc::new(Init::OpenRc, t.path()); + for level in ["default", "nonetwork", "boot"] { + fs::create_dir_all(t.path().join("etc/runlevels").join(level)).unwrap(); + symlink( + "/etc/init.d/foo", + t.path().join("etc/runlevels").join(level).join("foo"), + ) + .unwrap(); + } + assert_eq!(svc.disable("foo").unwrap(), Change::Disabled); + for level in ["default", "nonetwork", "boot"] { + assert!( + t.path() + .join("etc/runlevels") + .join(level) + .join("foo") + .symlink_metadata() + .is_err() + ); + } + } + + #[test] + fn runit_links_into_the_default_runsvdir() { + let t = TempDir::new(); + fs::create_dir_all(t.path().join("etc/runit/sv/cupsd")).unwrap(); + let svc = Svc::new(Init::Runit, t.path()); + assert_eq!(svc.enable("cupsd").unwrap(), Change::Enabled); + assert_eq!( + link_target(t.path(), "etc/runit/runsvdir/default/cupsd"), + Path::new("/etc/runit/sv/cupsd") + ); + assert_eq!(svc.disable("cupsd").unwrap(), Change::Disabled); + } + + #[test] + fn dinit_links_relative_into_boot_d() { + let t = TempDir::new(); + t.put("etc/dinit.d/NetworkManager", "type = process\n"); + let svc = Svc::new(Init::Dinit, t.path()); + assert_eq!(svc.enable("NetworkManager").unwrap(), Change::Enabled); + assert_eq!( + link_target(t.path(), "etc/dinit.d/boot.d/NetworkManager"), + Path::new("../NetworkManager") + ); + // The link resolves. + assert!(t.path().join("etc/dinit.d/boot.d/NetworkManager").exists()); + } + + #[test] + fn s6_uses_the_default_bundle_contents() { + let t = TempDir::new(); + fs::create_dir_all(t.path().join("etc/s6/sv/sshd")).unwrap(); + let svc = Svc::new(Init::S6, t.path()); + assert_eq!(svc.enable("sshd").unwrap(), Change::Enabled); + assert!( + t.path() + .join("etc/s6/adminsv/default/contents.d/sshd") + .exists() + ); + assert_eq!(svc.disable("sshd").unwrap(), Change::Disabled); + assert_eq!(svc.disable("sshd").unwrap(), Change::AlreadyDisabled); + } + + #[test] + fn unknown_and_unsafe_names_are_refused() { + let t = TempDir::new(); + let svc = Svc::new(Init::OpenRc, t.path()); + let missing = svc.enable("nothing").unwrap_err().to_string(); + assert!( + missing.contains("no service `nothing` for openrc"), + "{missing}" + ); + assert!(matches!(svc.enable("../x"), Err(Error::Usage(_)))); + assert!(matches!(svc.disable("a/b"), Err(Error::Usage(_)))); + assert!(!t.path().join("etc/runlevels").exists()); + } + + #[test] + fn list_combines_installed_and_enabled() { + let t = TempDir::new(); + t.put("etc/init.d/a", ""); + t.put("etc/init.d/b", ""); + t.put("etc/init.d/.keep", ""); + let svc = Svc::new(Init::OpenRc, t.path()); + svc.enable("b").unwrap(); + assert_eq!( + svc.list().unwrap(), + vec![ + Service { + name: "a".into(), + enabled: false, + state: State::Unknown + }, + Service { + name: "b".into(), + enabled: true, + state: State::Unknown + }, + ] + ); + + let t = TempDir::new(); + fs::create_dir_all(t.path().join("etc/runit/sv/x")).unwrap(); + fs::create_dir_all(t.path().join("etc/runit/sv/y")).unwrap(); + let svc = Svc::new(Init::Runit, t.path()); + svc.enable("y").unwrap(); + let list = svc.list().unwrap(); + assert_eq!( + list.iter() + .map(|s| (s.name.as_str(), s.enabled)) + .collect::>(), + [("x", false), ("y", true)] + ); + + // dinit: boot.d is a directory of links, not a service. + let t = TempDir::new(); + t.put("etc/dinit.d/sshd", ""); + let svc = Svc::new(Init::Dinit, t.path()); + svc.enable("sshd").unwrap(); + let list = svc.list().unwrap(); + assert_eq!(list.len(), 1); + assert!(list[0].enabled); + } + + #[test] + fn control_commands_per_init() { + let t = TempDir::new(); + t.put("etc/init.d/sshd", ""); + let svc = Svc::new(Init::OpenRc, t.path()); + assert_eq!( + svc.control_command(Action::Start, "sshd").unwrap(), + ( + "rc-service".to_string(), + vec!["sshd".to_string(), "start".to_string()] + ) + ); + + let t = TempDir::new(); + t.put("etc/dinit.d/sshd", ""); + let svc = Svc::new(Init::Dinit, t.path()); + assert_eq!( + svc.control_command(Action::Stop, "sshd").unwrap(), + ( + "dinitctl".to_string(), + vec!["stop".to_string(), "sshd".to_string()] + ) + ); + + let t = TempDir::new(); + fs::create_dir_all(t.path().join("etc/s6/sv/sshd")).unwrap(); + let svc = Svc::new(Init::S6, t.path()); + assert_eq!( + svc.control_command(Action::Start, "sshd").unwrap().1, + ["-u", "change", "sshd"] + ); + assert_eq!( + svc.control_command(Action::Stop, "sshd").unwrap().1, + ["-d", "change", "sshd"] + ); + + // runit supervises only what is enabled. + let t = TempDir::new(); + fs::create_dir_all(t.path().join("etc/runit/sv/sshd")).unwrap(); + let svc = Svc::new(Init::Runit, t.path()); + let e = svc + .control_command(Action::Start, "sshd") + .unwrap_err() + .to_string(); + assert!(e.contains("seg svc enable sshd"), "{e}"); + svc.enable("sshd").unwrap(); + let (program, args) = svc.control_command(Action::Start, "sshd").unwrap(); + assert_eq!(program, "sv"); + assert_eq!(args[0], "start"); + assert!(args[1].ends_with("etc/runit/runsvdir/default/sshd")); + } + + #[test] + fn rc_status_lines() { + let text = " acpid [ started ]\n \ + syslog-ng [ stopped ]\n \ + foo [ crashed ]\n \ + bar [ started 00:00:12 (0) ]\n\ + Runlevel: default\n\ + Dynamic Runlevel: hotplugged\n \ + baz [ starting ]\n"; + let m = parse_rc_status(text); + assert_eq!(m["acpid"], State::Started); + assert_eq!(m["syslog-ng"], State::Stopped); + assert_eq!(m["foo"], State::Crashed); + assert_eq!(m["bar"], State::Started); + assert_eq!(m["baz"], State::Unknown); + assert_eq!(m.len(), 5); + } + + #[test] + fn sv_status_lines() { + let text = "run: /etc/runit/runsvdir/default/sshd: (pid 1234) 5s; run: log: (pid 1233) 5s\n\ + down: /etc/runit/runsvdir/default/cupsd: 3s, normally up\n\ + fail: /etc/runit/runsvdir/default/gone: unable to change to service directory: file does not exist\n\ + warning: /etc/runit/runsvdir/default/new: unable to open supervise/ok: file does not exist\n"; + let m = parse_sv_status(text); + assert_eq!(m["sshd"], State::Started); + assert_eq!(m["cupsd"], State::Stopped); + assert_eq!(m["gone"], State::Crashed); + assert_eq!(m["new"], State::Stopped); + } + + #[test] + fn dinitctl_list_lines() { + let text = "[[+] ] boot\n\ + [{+} ] NetworkManager (pid: 450)\n\ + [{ } ] cupsd\n\ + not a service line\n"; + let m = parse_dinit_list(text); + assert_eq!(m["boot"], State::Started); + assert_eq!(m["NetworkManager"], State::Started); + assert_eq!(m["cupsd"], State::Stopped); + assert_eq!(m.len(), 3); + } +} diff --git a/tools/seg/segctl/src/sys.rs b/tools/seg/segctl/src/sys.rs new file mode 100644 index 0000000..28d4096 --- /dev/null +++ b/tools/seg/segctl/src/sys.rs @@ -0,0 +1,336 @@ +//! Small helpers over /proc, /sys and child processes. + +use std::fs; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use crate::error::{Context, Error, Result}; + +/// The effective user id, from `Uid: real effective saved fs` in +/// /proc/self/status (std has no geteuid, and this needs no libc crate). +pub fn parse_euid(status: &str) -> Option { + let line = status.lines().find(|l| l.starts_with("Uid:"))?; + line.split_whitespace().nth(2)?.parse().ok() +} + +pub fn euid() -> Result { + let status = fs::read_to_string("/proc/self/status").context("reading /proc/self/status")?; + parse_euid(&status) + .ok_or_else(|| Error::other("cannot read the user id from /proc/self/status")) +} + +/// Fails with a clear message unless running as root. `what` names the +/// command in the message, for example "`seg zram on`". +pub fn require_root(what: &str) -> Result<()> { + if euid()? == 0 { + Ok(()) + } else { + Err(Error::NeedRoot(what.to_string())) + } +} + +/// A file's trimmed contents, or None when it cannot be read. +pub fn read_trimmed(path: impl AsRef) -> Option { + fs::read_to_string(path).ok().map(|s| s.trim().to_string()) +} + +pub fn read(path: impl AsRef) -> Result { + let path = path.as_ref(); + fs::read_to_string(path).context(format!("reading {}", path.display())) +} + +pub fn write(path: impl AsRef, contents: &str) -> Result<()> { + let path = path.as_ref(); + fs::write(path, contents).context(format!("writing {}", path.display())) +} + +/// Replace a file in one step, so a crash never leaves half a configuration. +pub fn write_atomic(path: &Path, contents: &str) -> Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).context(format!("creating {}", dir.display()))?; + } + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".seg-tmp"); + let tmp = PathBuf::from(tmp); + write(&tmp, contents)?; + fs::rename(&tmp, path).context(format!("replacing {}", path.display())) +} + +pub struct Output { + pub ok: bool, + pub stdout: String, + pub stderr: String, +} + +/// Run a program and capture its output. Only a program that cannot be +/// started is an error; a failing exit status is reported in `ok`. +pub fn run(program: &str, args: &[&str]) -> Result { + let out = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .output() + .map_err(|e| Error::Command { + program: program.to_string(), + detail: format!("cannot start it: {e}"), + })?; + Ok(Output { + ok: out.status.success(), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }) +} + +/// Like [`run`] but a failing exit status is an error carrying stderr. +pub fn run_ok(program: &str, args: &[&str]) -> Result { + let out = run(program, args)?; + if out.ok { + Ok(out.stdout) + } else { + Err(Error::Command { + program: format!("{program} {}", args.join(" ")), + detail: out.stderr.trim().to_string(), + }) + } +} + +/// Run a program with the terminal attached, so its output and prompts show. +pub fn run_inherit(program: &str, args: &[&str]) -> Result<()> { + let status = Command::new(program) + .args(args) + .status() + .map_err(|e| Error::Command { + program: program.to_string(), + detail: format!("cannot start it: {e}"), + })?; + if status.success() { + Ok(()) + } else { + Err(Error::Command { + program: format!("{program} {}", args.join(" ")), + detail: format!("failed with {status}"), + }) + } +} + +pub fn which(program: &str) -> Option { + use std::os::unix::fs::PermissionsExt; + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(program)) + .find(|p| { + p.metadata() + .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + }) +} + +/// 1536 KiB -> "1.5 MiB". +pub fn human_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + match UNITS.get(unit) { + Some(&"B") | None => format!("{bytes} B"), + Some(u) => format!("{value:.1} {u}"), + } +} + +/// 8100 s -> "2h 15m". +pub fn human_duration(secs: u64) -> String { + let (d, h, m) = (secs / 86_400, secs % 86_400 / 3600, secs % 3600 / 60); + match (d, h, m) { + (0, 0, 0) => format!("{secs}s"), + (0, 0, m) => format!("{m}m"), + (0, h, m) => format!("{h}h {m}m"), + (d, h, _) => format!("{d}d {h}h"), + } +} + +/// Home directory of `user` from passwd-formatted text. +pub fn parse_passwd_home(passwd: &str, user: &str) -> Option { + passwd.lines().find_map(|line| { + let mut f = line.split(':'); + (f.next()? == user).then(|| f.nth(4).map(PathBuf::from))? + }) +} + +/// The home that `seg` reports on: the user who ran `sudo`, else the caller's. +pub fn invoking_home() -> Option { + if let Some(user) = std::env::var_os("SUDO_USER").and_then(|u| u.into_string().ok()) + && user != "root" + && let Ok(passwd) = fs::read_to_string("/etc/passwd") + && let Some(home) = parse_passwd_home(&passwd, &user) + { + return Some(home); + } + std::env::var_os("HOME").map(PathBuf::from) +} + +/// Total size of the files under `path`, not following links. Unreadable +/// entries count as zero: this is for reports, not for accounting. +pub fn dir_size(path: &Path) -> u64 { + let Ok(meta) = fs::symlink_metadata(path) else { + return 0; + }; + if meta.is_file() { + return meta.len(); + } + if !meta.is_dir() { + return 0; + } + let Ok(entries) = fs::read_dir(path) else { + return 0; + }; + entries.flatten().map(|e| dir_size(&e.path())).sum() +} + +/// Write to stdout; a closed pipe ends the program quietly, as other tools do. +pub fn emit(args: std::fmt::Arguments, newline: bool) { + use std::io::{ErrorKind, Write}; + let mut out = std::io::stdout().lock(); + let result = out.write_fmt(args).and_then(|()| { + if newline { + out.write_all(b"\n") + } else { + Ok(()) + } + }); + if let Err(e) = result + && e.kind() == ErrorKind::BrokenPipe + { + std::process::exit(0); + } +} + +/// Write a line to stderr, ignoring a stderr that cannot be written. +pub fn emit_err(args: std::fmt::Arguments) { + use std::io::Write; + let _ = writeln!(std::io::stderr().lock(), "{args}"); +} + +/// Colors only on a terminal, and not when NO_COLOR is set. +pub fn use_color() -> bool { + std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + +pub fn bold(text: &str) -> String { + if use_color() { + format!("\x1b[1m{text}\x1b[0m") + } else { + text.to_string() + } +} + +#[cfg(test)] +pub mod testutil { + use std::fs; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU32, Ordering}; + + /// A directory under the system temp dir, removed on drop. + pub struct TempDir(PathBuf); + + impl TempDir { + pub fn new() -> Self { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let dir = std::env::temp_dir().join(format!( + "seg-test-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&dir).unwrap(); + TempDir(dir) + } + + pub fn path(&self) -> &Path { + &self.0 + } + + /// Write `contents` to `rel` below the directory, creating parents. + pub fn put(&self, rel: &str, contents: &str) -> PathBuf { + let p = self.0.join(rel); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(&p, contents).unwrap(); + p + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } +} + +#[cfg(test)] +mod tests { + use super::testutil::TempDir; + use super::*; + + #[test] + fn euid_is_the_second_uid_field() { + let status = "Name:\tsh\nUid:\t1000\t0\t0\t0\nGid:\t1000\t1000\t1000\t1000\n"; + assert_eq!(parse_euid(status), Some(0)); + assert_eq!(parse_euid("Uid:\t1000\t1000\t1000\t1000\n"), Some(1000)); + assert_eq!(parse_euid("Name:\tsh\n"), None); + assert_eq!(parse_euid("Uid:\tx\ty\n"), None); + } + + #[test] + fn sizes_and_durations() { + assert_eq!(human_bytes(0), "0 B"); + assert_eq!(human_bytes(1023), "1023 B"); + assert_eq!(human_bytes(1536), "1.5 KiB"); + assert_eq!(human_bytes(5 * 1024 * 1024 * 1024), "5.0 GiB"); + assert_eq!(human_duration(42), "42s"); + assert_eq!(human_duration(600), "10m"); + assert_eq!(human_duration(8100), "2h 15m"); + assert_eq!(human_duration(90_000), "1d 1h"); + } + + #[test] + fn home_from_passwd() { + let passwd = + "root:x:0:0:root:/root:/bin/bash\nalice:x:1000:1000:Alice:/home/alice:/bin/zsh\n"; + assert_eq!( + parse_passwd_home(passwd, "alice"), + Some(PathBuf::from("/home/alice")) + ); + assert_eq!(parse_passwd_home(passwd, "bob"), None); + assert_eq!(parse_passwd_home("broken", "broken"), None); + } + + #[test] + fn dir_size_adds_files_and_ignores_links() { + let t = TempDir::new(); + t.put("a/one", "12345"); + t.put("a/b/two", "123"); + std::os::unix::fs::symlink(t.path().join("a"), t.path().join("link")).unwrap(); + assert_eq!(dir_size(&t.path().join("a")), 8); + assert_eq!(dir_size(t.path()), 8); + assert_eq!(dir_size(&t.path().join("missing")), 0); + } + + #[test] + fn atomic_write_replaces_and_creates_parents() { + let t = TempDir::new(); + let p = t.path().join("etc/seg/tune.conf"); + write_atomic(&p, "mode = light\n").unwrap(); + write_atomic(&p, "mode = balanced\n").unwrap(); + assert_eq!(fs::read_to_string(&p).unwrap(), "mode = balanced\n"); + assert!(!t.path().join("etc/seg/tune.conf.seg-tmp").exists()); + } + + #[test] + fn missing_program_is_an_error_not_a_panic() { + let e = run("definitely-not-a-program-seg", &[]).err().unwrap(); + assert!(matches!(e, Error::Command { .. })); + assert!(run("true", &[]).unwrap().ok); + assert!(!run("false", &[]).unwrap().ok); + assert!(run_ok("false", &[]).is_err()); + } +} diff --git a/tools/seg/segctl/src/tune.rs b/tools/seg/segctl/src/tune.rs new file mode 100644 index 0000000..49a5d81 --- /dev/null +++ b/tools/seg/segctl/src/tune.rs @@ -0,0 +1,866 @@ +//! `seg tune light|balanced|performance`: memory and writeback sysctls, the +//! CPU governor and the I/O scheduler of every disk, applied now and saved +//! for the next boot (/etc/sysctl.d/99-seg.conf, /etc/seg/tune.conf, and the +//! `seg-tune` init service that runs `seg tune apply`). +//! +//! All paths are looked up below a root directory, `/` on a real system, so +//! the whole thing can be tested against a fake /sys and /proc. + +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use crate::error::{Context, Error, Result}; +use crate::sys::{self, bold}; + +pub const HELP: &str = "\ +Usage: seg tune [light|balanced|performance|apply|status] [--dry-run] + + light save memory and battery: aggressive reclaim, small dirty + limits, the most frugal CPU governor + balanced kernel defaults for memory, a dynamic governor + performance keep caches, short writeback bursts, governor `performance` + apply apply the saved profile (the seg-tune service does this at boot) + status show the saved profile and the current values (default) + +Also sets the I/O scheduler of every disk: none for NVMe, mq-deadline for +SSDs, bfq for spinning disks. With zram swap on, swappiness is at least 100 +(compressed memory is cheaper to swap to than disk is to read from). + +The profile is saved in /etc/seg/tune.conf and /etc/sysctl.d/99-seg.conf. +Needs root, except `status` and --dry-run. +"; + +pub const CONF: &str = "/etc/seg/tune.conf"; +pub const SYSCTL_CONF: &str = "/etc/sysctl.d/99-seg.conf"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Light, + Balanced, + Performance, +} + +impl Mode { + pub fn name(self) -> &'static str { + match self { + Mode::Light => "light", + Mode::Balanced => "balanced", + Mode::Performance => "performance", + } + } +} + +impl fmt::Display for Mode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +impl FromStr for Mode { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "light" => Ok(Mode::Light), + "balanced" => Ok(Mode::Balanced), + "performance" => Ok(Mode::Performance), + other => Err(Error::Usage(format!( + "unknown profile `{other}` (light, balanced or performance)" + ))), + } + } +} + +struct Profile { + swappiness: u32, + vfs_cache_pressure: u32, + dirty_ratio: u32, + dirty_background_ratio: u32, + /// Governors in order of preference; the first the CPU offers is used. + /// (intel_pstate offers only performance and powersave, the latter being + /// its dynamic mode.) + governors: &'static [&'static str], + /// Energy/performance preference of CPUs that have one (HWP, amd-pstate). + epp: &'static str, +} + +fn profile(mode: Mode) -> Profile { + match mode { + Mode::Light => Profile { + swappiness: 100, + vfs_cache_pressure: 150, + dirty_ratio: 10, + dirty_background_ratio: 5, + governors: &["powersave", "conservative", "schedutil", "ondemand"], + epp: "power", + }, + Mode::Balanced => Profile { + swappiness: 60, + vfs_cache_pressure: 100, + dirty_ratio: 20, + dirty_background_ratio: 10, + governors: &["schedutil", "ondemand", "powersave"], + epp: "balance_performance", + }, + Mode::Performance => Profile { + swappiness: 10, + vfs_cache_pressure: 50, + dirty_ratio: 15, + dirty_background_ratio: 5, + governors: &["performance"], + epp: "performance", + }, + } +} + +/// The sysctl keys and values of a mode. +pub fn sysctl_values(mode: Mode, zram_active: bool) -> [(&'static str, u32); 4] { + let p = profile(mode); + let swappiness = if zram_active { + p.swappiness.max(100) + } else { + p.swappiness + }; + [ + ("vm.swappiness", swappiness), + ("vm.vfs_cache_pressure", p.vfs_cache_pressure), + ("vm.dirty_ratio", p.dirty_ratio), + ("vm.dirty_background_ratio", p.dirty_background_ratio), + ] +} + +pub fn sysctl_conf(mode: Mode, zram_active: bool) -> String { + let mut text = format!( + "# Written by `seg tune {mode}`; change it with `sudo seg tune `.\n" + ); + for (key, value) in sysctl_values(mode, zram_active) { + text.push_str(&format!("{key} = {value}\n")); + } + text +} + +/// `mode = balanced` from /etc/seg/tune.conf. +pub fn parse_conf(text: &str) -> Option { + text.lines().find_map(|l| { + let l = l.trim(); + if l.starts_with('#') { + return None; + } + let (key, value) = l.split_once('=')?; + (key.trim() == "mode").then(|| value.trim().parse().ok())? + }) +} + +/// The first of `preferred` that `available` (space separated) contains. +pub fn pick_governor<'a>(available: &str, preferred: &[&'a str]) -> Option<&'a str> { + let have: Vec<&str> = available.split_whitespace().collect(); + preferred.iter().copied().find(|g| have.contains(g)) +} + +/// `mq-deadline kyber [bfq] none` -> (all schedulers, the active one). +pub fn parse_schedulers(text: &str) -> (Vec, Option) { + let mut all = Vec::new(); + let mut active = None; + for word in text.split_whitespace() { + match word.strip_prefix('[').and_then(|w| w.strip_suffix(']')) { + Some(name) => { + active = Some(name.to_string()); + all.push(name.to_string()); + } + None => all.push(word.to_string()), + } + } + (all, active) +} + +/// Block devices that have no I/O scheduler worth setting. +fn skip_device(name: &str) -> bool { + ["loop", "ram", "zram", "dm-", "md", "sr", "fd", "nbd", "mtd"] + .iter() + .any(|p| name.starts_with(p)) +} + +/// none for NVMe (and virtual disks), mq-deadline for SSDs, bfq for spinning +/// disks. +pub fn scheduler_for(name: &str, rotational: bool) -> Option<&'static str> { + if skip_device(name) { + return None; + } + Some( + if name.starts_with("nvme") || name.starts_with("vd") || name.starts_with("xvd") { + // NVMe schedules itself; so does the host of a virtual disk. + "none" + } else if rotational { + "bfq" + } else { + "mq-deadline" + }, + ) +} + +/// A system's files, below `root`. +pub struct Fs { + root: PathBuf, +} + +#[derive(Debug, Default)] +pub struct Report { + pub lines: Vec, + pub warnings: Vec, +} + +impl Fs { + pub fn new(root: impl Into) -> Self { + Fs { root: root.into() } + } + + fn p(&self, rel: &str) -> PathBuf { + self.root.join(rel.trim_start_matches('/')) + } + + pub fn zram_active(&self) -> bool { + sys::read_trimmed(self.p("/proc/swaps")) + .is_some_and(|t| t.lines().any(|l| l.starts_with("/dev/zram"))) + } + + pub fn saved_mode(&self) -> Option { + sys::read_trimmed(self.p(CONF)).and_then(|t| parse_conf(&t)) + } + + fn sysctl_path(&self, key: &str) -> PathBuf { + self.p(&format!("/proc/sys/{}", key.replace('.', "/"))) + } + + fn sysctl_now(&self, key: &str) -> Option { + sys::read_trimmed(self.sysctl_path(key)) + } + + /// Set a file's value if it differs; the report says what changed. + fn set( + &self, + label: &str, + path: &Path, + value: &str, + dry_run: bool, + report: &mut Report, + ) -> Result<()> { + let Some(now) = sys::read_trimmed(path) else { + report + .warnings + .push(format!("{label}: not available on this system")); + return Ok(()); + }; + if now == value { + return Ok(()); + } + if !dry_run { + fs::write(path, value).context(format!("setting {label} ({})", path.display()))?; + } + report.lines.push(format!("{label:<28}{now} -> {value}")); + Ok(()) + } + + fn apply_sysctl(&self, mode: Mode, dry_run: bool, report: &mut Report) -> Result<()> { + for (key, value) in sysctl_values(mode, self.zram_active()) { + self.set( + key, + &self.sysctl_path(key), + &value.to_string(), + dry_run, + report, + )?; + } + Ok(()) + } + + fn apply_cpu(&self, mode: Mode, dry_run: bool, report: &mut Report) -> Result<()> { + let p = profile(mode); + let Ok(entries) = fs::read_dir(self.p("/sys/devices/system/cpu")) else { + return Ok(()); + }; + let mut cpus: Vec = entries + .flatten() + .filter(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + name.strip_prefix("cpu") + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + }) + .map(|e| e.path().join("cpufreq")) + .filter(|d| d.is_dir()) + .collect(); + cpus.sort(); + if cpus.is_empty() { + report + .warnings + .push("cpu governor: this CPU has no frequency scaling (virtual machine?)".into()); + return Ok(()); + } + let mut changed = 0; + let mut used = None; + for dir in &cpus { + let available = + sys::read_trimmed(dir.join("scaling_available_governors")).unwrap_or_default(); + let Some(governor) = pick_governor(&available, p.governors) else { + continue; + }; + used = Some(governor); + let path = dir.join("scaling_governor"); + if sys::read_trimmed(&path).as_deref() != Some(governor) { + if !dry_run { + fs::write(&path, governor) + .context(format!("setting the governor ({})", path.display()))?; + } + changed += 1; + } + // Best effort: with the performance governor the CPU refuses. + let epp = dir.join("energy_performance_preference"); + if epp.exists() && sys::read_trimmed(&epp).as_deref() != Some(p.epp) && !dry_run { + let _ = fs::write(&epp, p.epp); + } + } + match used { + Some(g) if changed > 0 => report.lines.push(format!( + "{:<28}{g} on {changed} of {} CPUs", + "cpu governor", + cpus.len() + )), + Some(_) => {} + None => report.warnings.push(format!( + "cpu governor: none of {} is available on this CPU", + p.governors.join(", ") + )), + } + Ok(()) + } + + fn apply_io(&self, dry_run: bool, report: &mut Report) -> Result<()> { + let Ok(entries) = fs::read_dir(self.p("/sys/block")) else { + return Ok(()); + }; + let mut devices: Vec = entries + .flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + devices.sort(); + for name in devices { + let queue = self.p(&format!("/sys/block/{name}/queue")); + let Some(text) = sys::read_trimmed(queue.join("scheduler")) else { + continue; + }; + let rotational = sys::read_trimmed(queue.join("rotational")).as_deref() != Some("0"); + let Some(want) = scheduler_for(&name, rotational) else { + continue; + }; + let (all, active) = parse_schedulers(&text); + if active.as_deref() == Some(want) || text == want { + continue; + } + if !all.iter().any(|s| s == want) { + report.warnings.push(format!( + "{name}: scheduler {want} is not built into this kernel (has {})", + all.join(", ") + )); + continue; + } + if !dry_run { + fs::write(queue.join("scheduler"), want) + .context(format!("setting the scheduler of {name}"))?; + } + report.lines.push(format!( + "{:<28}{} -> {want}", + format!("{name} scheduler"), + active.as_deref().unwrap_or("?") + )); + } + Ok(()) + } + + /// Apply `mode` now; unless `dry_run`, also save it for the next boot. + pub fn apply(&self, mode: Mode, save: bool, dry_run: bool) -> Result { + let mut report = Report::default(); + self.apply_sysctl(mode, dry_run, &mut report)?; + self.apply_cpu(mode, dry_run, &mut report)?; + self.apply_io(dry_run, &mut report)?; + if save && !dry_run { + sys::write_atomic(&self.p(SYSCTL_CONF), &sysctl_conf(mode, self.zram_active()))?; + sys::write_atomic( + &self.p(CONF), + &format!("# seg tune profile\nmode = {mode}\n"), + )?; + report.lines.push(format!("saved {CONF} and {SYSCTL_CONF}")); + } + Ok(report) + } + + /// The memory sysctls again for the saved profile: zram swap coming or + /// going changes the swappiness it wants. Does nothing without a profile. + pub fn reapply_memory(&self) -> Result<()> { + if let Some(mode) = self.saved_mode() { + let mut report = Report::default(); + self.apply_sysctl(mode, false, &mut report)?; + } + Ok(()) + } + + pub fn status(&self) -> String { + let mut out = String::new(); + match self.saved_mode() { + Some(mode) => out.push_str(&format!("profile: {mode}\n")), + None => out.push_str("profile: none saved (`sudo seg tune balanced` saves one)\n"), + } + for (key, _) in sysctl_values(Mode::Balanced, false) { + out.push_str(&format!( + " {key:<28}{}\n", + self.sysctl_now(key).unwrap_or_else(|| "?".into()) + )); + } + let governor = + sys::read_trimmed(self.p("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")); + out.push_str(&format!( + " {:<28}{}\n", + "cpu governor", + governor.unwrap_or_else(|| "- (no frequency scaling)".into()) + )); + if let Ok(entries) = fs::read_dir(self.p("/sys/block")) { + let mut devices: Vec = entries + .flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| !skip_device(n)) + .collect(); + devices.sort(); + for name in devices { + if let Some(text) = + sys::read_trimmed(self.p(&format!("/sys/block/{name}/queue/scheduler"))) + { + let (_, active) = parse_schedulers(&text); + out.push_str(&format!( + " {:<28}{}\n", + format!("{name} scheduler"), + active.unwrap_or_else(|| "?".into()) + )); + } + } + } + out.push_str(&format!( + " {:<28}{}\n", + "zram swap", + if self.zram_active() { "on" } else { "off" } + )); + out + } +} + +pub enum Command { + Set(Mode), + Apply, + Status, +} + +pub struct Options { + pub command: Command, + pub dry_run: bool, +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut command = None; + let mut dry_run = false; + while let Some(arg) = parser.next()? { + match arg { + Long("dry-run") => dry_run = true, + Short('h') | Long("help") => { + out!("{HELP}"); + std::process::exit(0); + } + Value(v) if command.is_none() => { + let v = v.string()?; + command = Some(match v.as_str() { + "apply" => Command::Apply, + "status" => Command::Status, + other => Command::Set(other.parse()?), + }); + } + other => return Err(other.unexpected().into()), + } + } + Ok(Options { + command: command.unwrap_or(Command::Status), + dry_run, + }) +} + +fn print_report(report: &Report) { + for line in &report.lines { + outln!(" {line}"); + } + for warning in &report.warnings { + outln!(" note: {warning}"); + } +} + +pub fn run(o: Options) -> Result<()> { + let fs = Fs::new("/"); + match o.command { + Command::Status => { + out!("{}", fs.status()); + Ok(()) + } + Command::Set(mode) => { + if !o.dry_run { + sys::require_root(&format!("`seg tune {mode}`"))?; + } + outln!( + "{}", + bold(&format!( + "seg tune {mode}{}", + if o.dry_run { " (dry run)" } else { "" } + )) + ); + let report = fs.apply(mode, true, o.dry_run)?; + if report.lines.is_empty() && report.warnings.is_empty() { + outln!(" already set"); + } + print_report(&report); + Ok(()) + } + Command::Apply => { + if !o.dry_run { + sys::require_root("`seg tune apply`")?; + } + // Nothing saved is fine: the service is enabled in the image + // before anyone has chosen a profile. + let Some(mode) = fs.saved_mode() else { + outln!("seg tune: no profile saved ({CONF}); nothing to apply"); + return Ok(()); + }; + outln!("{}", bold(&format!("seg tune apply ({mode})"))); + print_report(&fs.apply(mode, false, o.dry_run)?); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + #[test] + fn modes_parse_and_print() { + for (text, mode) in [ + ("light", Mode::Light), + ("balanced", Mode::Balanced), + ("performance", Mode::Performance), + ] { + assert_eq!(text.parse::().unwrap(), mode); + assert_eq!(mode.to_string(), text); + } + let e = "turbo".parse::().unwrap_err(); + assert!(matches!(e, Error::Usage(_))); + assert!(e.to_string().contains("light, balanced or performance")); + } + + #[test] + fn profiles_are_ordered_from_frugal_to_fast() { + let swap = |m| sysctl_values(m, false)[0].1; + assert!(swap(Mode::Light) > swap(Mode::Balanced)); + assert!(swap(Mode::Balanced) > swap(Mode::Performance)); + let vfs = |m| sysctl_values(m, false)[1].1; + assert!( + vfs(Mode::Light) > vfs(Mode::Balanced) && vfs(Mode::Balanced) > vfs(Mode::Performance) + ); + } + + #[test] + fn zram_raises_swappiness_to_at_least_100() { + assert_eq!( + sysctl_values(Mode::Performance, true)[0], + ("vm.swappiness", 100) + ); + assert_eq!( + sysctl_values(Mode::Balanced, true)[0], + ("vm.swappiness", 100) + ); + assert_eq!( + sysctl_values(Mode::Performance, false)[0], + ("vm.swappiness", 10) + ); + // Never lowered. + assert_eq!(sysctl_values(Mode::Light, true)[0].1, 100); + } + + #[test] + fn sysctl_file_is_plain_key_value() { + let text = sysctl_conf(Mode::Balanced, false); + assert!(text.starts_with('#')); + assert!(text.contains("\nvm.swappiness = 60\n")); + assert!(text.contains("\nvm.vfs_cache_pressure = 100\n")); + assert!(text.contains("\nvm.dirty_ratio = 20\n")); + assert!(text.contains("\nvm.dirty_background_ratio = 10\n")); + } + + #[test] + fn the_shipped_defaults_are_the_balanced_profile() { + // dist/ is what the package installs as /etc/sysctl.d/99-seg.conf and + // /etc/seg/tune.conf: it must say what `seg tune balanced` writes. + assert_eq!( + include_str!("../../dist/99-seg.conf"), + sysctl_conf(Mode::Balanced, false) + ); + assert_eq!( + parse_conf(include_str!("../../dist/tune.conf")), + Some(Mode::Balanced) + ); + } + + #[test] + fn saved_mode_file() { + assert_eq!( + parse_conf("# seg tune profile\nmode = performance\n"), + Some(Mode::Performance) + ); + assert_eq!(parse_conf("mode=light"), Some(Mode::Light)); + assert_eq!(parse_conf("mode = turbo\n"), None); + assert_eq!(parse_conf("# mode = light\n"), None); + assert_eq!(parse_conf(""), None); + } + + #[test] + fn governors_follow_the_preference_order() { + let modern = "performance powersave"; + let acpi = "conservative ondemand userspace powersave performance schedutil"; + assert_eq!( + pick_governor(modern, profile(Mode::Balanced).governors), + Some("powersave") + ); + assert_eq!( + pick_governor(modern, profile(Mode::Performance).governors), + Some("performance") + ); + assert_eq!( + pick_governor(acpi, profile(Mode::Balanced).governors), + Some("schedutil") + ); + assert_eq!( + pick_governor(acpi, profile(Mode::Light).governors), + Some("powersave") + ); + assert_eq!( + pick_governor("userspace", profile(Mode::Performance).governors), + None + ); + assert_eq!(pick_governor("", profile(Mode::Light).governors), None); + } + + #[test] + fn scheduler_lists() { + assert_eq!( + parse_schedulers("mq-deadline kyber [bfq] none"), + ( + vec![ + "mq-deadline".into(), + "kyber".into(), + "bfq".into(), + "none".into() + ], + Some("bfq".into()) + ) + ); + assert_eq!(parse_schedulers("none"), (vec!["none".into()], None)); + assert_eq!(parse_schedulers(""), (vec![], None)); + } + + #[test] + fn scheduler_by_disk_type() { + assert_eq!(scheduler_for("nvme0n1", false), Some("none")); + assert_eq!(scheduler_for("sda", false), Some("mq-deadline")); + assert_eq!(scheduler_for("sdb", true), Some("bfq")); + assert_eq!(scheduler_for("mmcblk0", false), Some("mq-deadline")); + // Virtual disks claim to spin; their host does the scheduling. + assert_eq!(scheduler_for("vda", true), Some("none")); + assert_eq!(scheduler_for("xvda", true), Some("none")); + for skipped in ["loop0", "zram0", "dm-0", "md127", "sr0", "ram0", "nbd0"] { + assert_eq!(scheduler_for(skipped, true), None, "{skipped}"); + } + } + + /// A machine with two CPUs, an NVMe drive, an SSD, a hard disk and a loop + /// device. + fn fake_system() -> TempDir { + let t = TempDir::new(); + for cpu in ["cpu0", "cpu1"] { + let d = format!("sys/devices/system/cpu/{cpu}/cpufreq"); + t.put( + &format!("{d}/scaling_available_governors"), + "performance schedutil powersave\n", + ); + t.put(&format!("{d}/scaling_governor"), "powersave\n"); + t.put( + &format!("{d}/energy_performance_preference"), + "balance_performance\n", + ); + } + t.put("sys/devices/system/cpu/cpufreq/policy0/x", ""); + t.put("sys/devices/system/cpu/online", "0-1\n"); + t.put("sys/block/nvme0n1/queue/scheduler", "[none] mq-deadline\n"); + t.put("sys/block/nvme0n1/queue/rotational", "0\n"); + t.put( + "sys/block/sda/queue/scheduler", + "[none] mq-deadline kyber bfq\n", + ); + t.put("sys/block/sda/queue/rotational", "0\n"); + t.put( + "sys/block/sdb/queue/scheduler", + "mq-deadline [none] kyber bfq\n", + ); + t.put("sys/block/sdb/queue/rotational", "1\n"); + t.put("sys/block/loop0/queue/scheduler", "[none]\n"); + t.put("proc/sys/vm/swappiness", "60\n"); + t.put("proc/sys/vm/vfs_cache_pressure", "100\n"); + t.put("proc/sys/vm/dirty_ratio", "20\n"); + t.put("proc/sys/vm/dirty_background_ratio", "10\n"); + t.put("proc/swaps", "Filename Type Size Used Priority\n"); + t + } + + fn read(t: &TempDir, rel: &str) -> String { + fs::read_to_string(t.path().join(rel)) + .unwrap() + .trim() + .to_string() + } + + #[test] + fn performance_changes_everything_and_saves_it() { + let t = fake_system(); + let report = Fs::new(t.path()) + .apply(Mode::Performance, true, false) + .unwrap(); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "10"); + assert_eq!(read(&t, "proc/sys/vm/vfs_cache_pressure"), "50"); + assert_eq!(read(&t, "proc/sys/vm/dirty_ratio"), "15"); + assert_eq!(read(&t, "proc/sys/vm/dirty_background_ratio"), "5"); + for cpu in ["cpu0", "cpu1"] { + assert_eq!( + read( + &t, + &format!("sys/devices/system/cpu/{cpu}/cpufreq/scaling_governor") + ), + "performance" + ); + assert_eq!( + read( + &t, + &format!("sys/devices/system/cpu/{cpu}/cpufreq/energy_performance_preference") + ), + "performance" + ); + } + // NVMe already had none; the SSD and the hard disk change. + assert_eq!( + read(&t, "sys/block/nvme0n1/queue/scheduler"), + "[none] mq-deadline" + ); + assert_eq!(read(&t, "sys/block/sda/queue/scheduler"), "mq-deadline"); + assert_eq!(read(&t, "sys/block/sdb/queue/scheduler"), "bfq"); + assert_eq!(read(&t, "sys/block/loop0/queue/scheduler"), "[none]"); + assert!( + report + .lines + .iter() + .any(|l| l.contains("performance on 2 of 2 CPUs")) + ); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + + assert_eq!( + read(&t, "etc/seg/tune.conf").lines().last(), + Some("mode = performance") + ); + let sysctl = read(&t, "etc/sysctl.d/99-seg.conf"); + assert!(sysctl.contains("vm.swappiness = 10")); + assert_eq!(Fs::new(t.path()).saved_mode(), Some(Mode::Performance)); + } + + #[test] + fn applying_twice_changes_nothing_the_second_time() { + let t = fake_system(); + let fs_ = Fs::new(t.path()); + fs_.apply(Mode::Balanced, true, false).unwrap(); + let again = fs_.apply(Mode::Balanced, false, false).unwrap(); + assert!(again.lines.is_empty(), "{:?}", again.lines); + } + + #[test] + fn a_dry_run_touches_nothing() { + let t = fake_system(); + let report = Fs::new(t.path()).apply(Mode::Light, true, true).unwrap(); + assert!(!report.lines.is_empty()); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "60"); + assert_eq!( + read(&t, "sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"), + "powersave" + ); + assert_eq!( + read(&t, "sys/block/sdb/queue/scheduler"), + "mq-deadline [none] kyber bfq" + ); + assert!(!t.path().join("etc/seg/tune.conf").exists()); + assert!(!t.path().join("etc/sysctl.d/99-seg.conf").exists()); + } + + #[test] + fn zram_swap_lifts_swappiness() { + let t = fake_system(); + t.put( + "proc/swaps", + "Filename Type Size Used Priority\n/dev/zram0 partition 4194300 0 100\n", + ); + let fs_ = Fs::new(t.path()); + assert!(fs_.zram_active()); + fs_.apply(Mode::Performance, true, false).unwrap(); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "100"); + assert!(read(&t, "etc/sysctl.d/99-seg.conf").contains("vm.swappiness = 100")); + // zram goes away: the saved profile's own value comes back. + t.put("proc/swaps", "Filename Type Size Used Priority\n"); + fs_.reapply_memory().unwrap(); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "10"); + } + + #[test] + fn missing_pieces_are_notes_not_failures() { + // A virtual machine: no cpufreq, a disk without bfq, no vm sysctl. + let t = TempDir::new(); + t.put("sys/devices/system/cpu/cpu0/online", "1\n"); + t.put("sys/block/sdz/queue/scheduler", "[none] mq-deadline\n"); + t.put("sys/block/sdz/queue/rotational", "1\n"); + let report = Fs::new(t.path()).apply(Mode::Light, false, false).unwrap(); + let notes = report.warnings.join("\n"); + assert!(notes.contains("no frequency scaling"), "{notes}"); + assert!( + notes.contains("sdz: scheduler bfq is not built into this kernel"), + "{notes}" + ); + assert!(notes.contains("vm.swappiness: not available"), "{notes}"); + } + + #[test] + fn reapply_without_a_profile_does_nothing() { + let t = fake_system(); + Fs::new(t.path()).reapply_memory().unwrap(); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "60"); + } + + #[test] + fn status_lists_the_current_values() { + let t = fake_system(); + let text = Fs::new(t.path()).status(); + assert!(text.contains("profile: none saved")); + assert!(text.contains("vm.swappiness")); + assert!(text.contains("nvme0n1 scheduler")); + assert!(!text.contains("loop0")); + assert!(text.contains("zram swap") && text.contains("off")); + } +} diff --git a/tools/seg/segctl/src/vercmp.rs b/tools/seg/segctl/src/vercmp.rs new file mode 100644 index 0000000..35993fe --- /dev/null +++ b/tools/seg/segctl/src/vercmp.rs @@ -0,0 +1,244 @@ +//! Package versions and cache file names, as pacman orders and writes them. +//! `vercmp` follows libalpm's `rpmvercmp`, so `seg clean` keeps the same +//! "newest" packages `paccache` would. + +use std::cmp::Ordering; + +fn is_alnum(b: u8) -> bool { + b.is_ascii_alphanumeric() +} + +/// Compare two version strings without epoch or release (libalpm's +/// `rpmvercmp`). +fn rpmvercmp(a: &str, b: &str) -> Ordering { + if a == b { + return Ordering::Equal; + } + let (a, b) = (a.as_bytes(), b.as_bytes()); + let (mut i, mut j) = (0, 0); + let at = |s: &[u8], k: usize| s.get(k).copied(); + while at(a, i).is_some() && at(b, j).is_some() { + let (start_i, start_j) = (i, j); + while at(a, i).is_some_and(|c| !is_alnum(c)) { + i += 1; + } + while at(b, j).is_some_and(|c| !is_alnum(c)) { + j += 1; + } + if at(a, i).is_none() || at(b, j).is_none() { + break; + } + // Different separator lengths decide it. + if i - start_i != j - start_j { + return if i - start_i < j - start_j { + Ordering::Less + } else { + Ordering::Greater + }; + } + // A whole numeric or whole alphabetic segment from each side. + let numeric = at(a, i).is_some_and(|c| c.is_ascii_digit()); + let (mut ei, mut ej) = (i, j); + if numeric { + while at(a, ei).is_some_and(|c| c.is_ascii_digit()) { + ei += 1; + } + while at(b, ej).is_some_and(|c| c.is_ascii_digit()) { + ej += 1; + } + } else { + while at(a, ei).is_some_and(|c| c.is_ascii_alphabetic()) { + ei += 1; + } + while at(b, ej).is_some_and(|c| c.is_ascii_alphabetic()) { + ej += 1; + } + } + let (mut seg_a, mut seg_b) = (a.get(i..ei).unwrap_or(&[]), b.get(j..ej).unwrap_or(&[])); + // The other side has a segment of the other kind: numbers win. + if seg_b.is_empty() { + return if numeric { + Ordering::Greater + } else { + Ordering::Less + }; + } + if numeric { + while seg_a.first() == Some(&b'0') { + seg_a = seg_a.get(1..).unwrap_or(&[]); + } + while seg_b.first() == Some(&b'0') { + seg_b = seg_b.get(1..).unwrap_or(&[]); + } + // More digits is the bigger number. + match seg_a.len().cmp(&seg_b.len()) { + Ordering::Equal => {} + other => return other, + } + } + match seg_a.cmp(seg_b) { + Ordering::Equal => {} + other => return other, + } + i = ei; + j = ej; + } + // Everything compared equal; only separators may differ. + match (at(a, i), at(b, j)) { + (None, None) => Ordering::Equal, + // The longer version wins, unless what is left of it is letters + // (1.5b is older than 1.5). + (a_rest, b_rest) => { + let a_empty_b_not_alpha = + a_rest.is_none() && !b_rest.is_some_and(|c| c.is_ascii_alphabetic()); + if a_empty_b_not_alpha || a_rest.is_some_and(|c| c.is_ascii_alphabetic()) { + Ordering::Less + } else { + Ordering::Greater + } + } + } +} + +/// Split `[epoch:]version[-release]`. +fn split_evr(evr: &str) -> (&str, &str, Option<&str>) { + let digits = evr.bytes().take_while(u8::is_ascii_digit).count(); + let (epoch, rest) = match evr.get(digits..).and_then(|r| r.strip_prefix(':')) { + Some(rest) => (if digits == 0 { "0" } else { &evr[..digits] }, rest), + None => ("0", evr), + }; + match rest.rsplit_once('-') { + Some((version, release)) => (epoch, version, Some(release)), + None => (epoch, rest, None), + } +} + +/// Compare two package versions such as `1:2.4-1` and `2.3.9-3` the way +/// `vercmp` does. +pub fn vercmp(a: &str, b: &str) -> Ordering { + if a == b { + return Ordering::Equal; + } + let (e1, v1, r1) = split_evr(a); + let (e2, v2, r2) = split_evr(b); + rpmvercmp(e1, e2) + .then_with(|| rpmvercmp(v1, v2)) + .then_with(|| match (r1, r2) { + (Some(r1), Some(r2)) => rpmvercmp(r1, r2), + _ => Ordering::Equal, + }) +} + +/// A package file of the pacman cache. +#[derive(Debug, PartialEq, Eq)] +pub struct CacheFile { + pub name: String, + /// `[epoch:]pkgver-pkgrel`. + pub version: String, + pub arch: String, + /// A detached signature (`.sig`) of the package file. + pub is_sig: bool, +} + +/// `name-1:2.0-3-x86_64.pkg.tar.zst[.sig]` -> its parts. Other files in the +/// cache (partial downloads, `download-*` folders) give None. +pub fn parse_cache_file(file_name: &str) -> Option { + let (stem, is_sig) = match file_name.strip_suffix(".sig") { + Some(stem) => (stem, true), + None => (file_name, false), + }; + let (base, ext) = stem.split_once(".pkg.tar")?; + // The compressors makepkg knows; a bare ".pkg.tar" is also valid. Anything + // else (".zst.part", a partial download) is not a finished package. + if !matches!( + ext, + "" | ".zst" | ".xz" | ".gz" | ".bz2" | ".lz4" | ".lz" | ".lrz" | ".lzo" | ".Z" + ) { + return None; + } + let (rest, arch) = base.rsplit_once('-')?; + let (rest, release) = rest.rsplit_once('-')?; + let (name, version) = rest.rsplit_once('-')?; + if name.is_empty() || version.is_empty() || release.is_empty() || arch.is_empty() { + return None; + } + Some(CacheFile { + name: name.to_string(), + version: format!("{version}-{release}"), + arch: arch.to_string(), + is_sig, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use Ordering::*; + + // Cases from pacman's own vercmp test suite (test/util/vercmptest.sh). + #[test] + fn matches_pacmans_vercmp() { + let cases = [ + ("1.5.0", "1.5.0", Equal), + ("1.5.1", "1.5.0", Greater), + ("1.5.1", "1.5", Greater), + ("1.5.0-1", "1.5.0-1", Equal), + ("1.5.0-1", "1.5.0-2", Less), + ("1.5.0-1", "1.5.1-1", Less), + ("1.5.0-2", "1.5.1-1", Less), + ("1.5-1", "1.5.b2-1", Less), + ("1.5b-1", "1.5-1", Less), + ("1.5-1", "1.5b-1", Greater), + ("1.5.0", "1.5", Greater), + ("1.0", "1.0.1", Less), + ("1.0", "1.0a", Greater), + ("1.0a", "1.0b", Less), + ("1.0a", "1.0.1", Less), + ("1.9", "1.10", Less), + ("1.010", "1.10", Equal), + ("1.1000", "1.999", Greater), + ("1:1.0", "2.0", Greater), + ("1:1.0", "0:2.0", Greater), + ("2.0", "0:2.0", Equal), + ("1:1.0-1", "1:1.0-2", Less), + ("1.0_1", "1.0.1", Equal), + ("1.0+1", "1.0", Greater), + ]; + for (a, b, want) in cases { + assert_eq!(vercmp(a, b), want, "vercmp {a} {b}"); + assert_eq!(vercmp(b, a), want.reverse(), "vercmp {b} {a}"); + } + } + + #[test] + fn evr_parts() { + assert_eq!(split_evr("1:2.0-3"), ("1", "2.0", Some("3"))); + assert_eq!(split_evr("2.0-3"), ("0", "2.0", Some("3"))); + assert_eq!(split_evr("2.0"), ("0", "2.0", None)); + assert_eq!(split_evr(":2.0-3"), ("0", "2.0", Some("3"))); + assert_eq!(split_evr("a:2.0"), ("0", "a:2.0", None)); + } + + #[test] + fn cache_file_names() { + let f = parse_cache_file("linux-zen-headers-6.12.4.zen1-1-x86_64.pkg.tar.zst").unwrap(); + assert_eq!(f.name, "linux-zen-headers"); + assert_eq!(f.version, "6.12.4.zen1-1"); + assert_eq!(f.arch, "x86_64"); + assert!(!f.is_sig); + + let f = parse_cache_file("qt6-base-1:6.8.1-2-x86_64.pkg.tar.xz.sig").unwrap(); + assert_eq!(f.name, "qt6-base"); + assert_eq!(f.version, "1:6.8.1-2"); + assert!(f.is_sig); + + let f = parse_cache_file("filesystem-2024.11-1-any.pkg.tar.zst").unwrap(); + assert_eq!((f.name.as_str(), f.arch.as_str()), ("filesystem", "any")); + + assert!(parse_cache_file("download-abc123").is_none()); + assert!(parse_cache_file("foo-1.0-1-x86_64.pkg.tar.zst.part").is_none()); + assert!(parse_cache_file("foo.pkg.tar.zst").is_none()); + assert!(parse_cache_file("-1.0-1-x86_64.pkg.tar.zst").is_none()); + assert!(parse_cache_file("readme.txt").is_none()); + } +} diff --git a/tools/seg/segctl/src/zram.rs b/tools/seg/segctl/src/zram.rs new file mode 100644 index 0000000..15ee644 --- /dev/null +++ b/tools/seg/segctl/src/zram.rs @@ -0,0 +1,573 @@ +//! `seg zram on|off|status`: compressed swap in memory, without systemd. +//! zstd, half the RAM (at most 8 GiB: the table of a larger device costs +//! memory of its own), priority 100 so it is used before any disk swap. + +use std::fs; +use std::path::PathBuf; +use std::thread::sleep; +use std::time::Duration; + +use crate::error::{Error, Result}; +use crate::sys::{self, human_bytes}; +use crate::tune; + +pub const HELP: &str = "\ +Usage: seg zram on|off|status [--size SIZE] + + on set up /dev/zram0 as swap: zstd, half of the RAM (at most 8G) + off stop using it and free its memory + status show whether it is on, its size and how well it compresses + +--size SIZE size of the device instead of half the RAM: 2G, 4096M, 512M + +`on` and `off` need root. The seg-zram service runs `seg zram on` at boot. +"; + +const MAX_AUTO_BYTES: u64 = 8 << 30; +const DEVICE: &str = "/dev/zram0"; + +pub enum Action { + On, + Off, + Status, +} + +pub struct Options { + pub action: Action, + pub size: Option, +} + +/// `2G`, `4096M`, `512m`, `1048576` (bytes) -> bytes. +pub fn parse_size(text: &str) -> Result { + let bad = || Error::Usage(format!("`{text}` is not a size (try 2G or 4096M)")); + let text = text.trim(); + let (digits, shift) = match text.chars().last() { + Some('G' | 'g') => (&text[..text.len() - 1], 30), + Some('M' | 'm') => (&text[..text.len() - 1], 20), + Some('K' | 'k') => (&text[..text.len() - 1], 10), + _ => (text, 0), + }; + let n: u64 = digits.parse().map_err(|_| bad())?; + let bytes = n.checked_mul(1 << shift).ok_or_else(bad)?; + if bytes == 0 { + return Err(bad()); + } + Ok(bytes) +} + +/// Half the RAM, at most 8 GiB. +pub fn auto_size(mem_total_kib: u64) -> u64 { + (mem_total_kib.saturating_mul(1024) / 2).min(MAX_AUTO_BYTES) +} + +pub fn mem_total_kib(meminfo: &str) -> Option { + meminfo + .lines() + .find(|l| l.starts_with("MemTotal:"))? + .split_whitespace() + .nth(1)? + .parse() + .ok() +} + +#[derive(Debug, PartialEq, Eq)] +pub struct SwapEntry { + pub path: String, + pub size_kib: u64, + pub used_kib: u64, + pub priority: i64, +} + +/// /proc/swaps: `Filename Type Size Used Priority`, sizes in KiB. +pub fn parse_swaps(text: &str) -> Vec { + text.lines() + .skip(1) + .filter_map(|l| { + let mut f = l.split_whitespace(); + let path = f.next()?.to_string(); + let _kind = f.next()?; + Some(SwapEntry { + path, + size_kib: f.next()?.parse().ok()?, + used_kib: f.next()?.parse().ok()?, + priority: f.next()?.parse().ok()?, + }) + }) + .collect() +} + +/// `lzo lzo-rle [lz4] zstd` -> (available, selected). +pub fn parse_algorithms(text: &str) -> (Vec, Option) { + let mut all = Vec::new(); + let mut selected = None; + for w in text.split_whitespace() { + match w.strip_prefix('[').and_then(|w| w.strip_suffix(']')) { + Some(name) => { + selected = Some(name.to_string()); + all.push(name.to_string()); + } + None => all.push(w.to_string()), + } + } + (all, selected) +} + +/// The first three numbers of mm_stat: original size, compressed size and +/// the memory used, in bytes. +pub fn parse_mm_stat(text: &str) -> Option<(u64, u64, u64)> { + let mut f = text.split_whitespace().map(|w| w.parse::()); + Some((f.next()?.ok()?, f.next()?.ok()?, f.next()?.ok()?)) +} + +/// Runs the helper programs; replaced by a recorder in tests. +pub trait Runner { + fn run(&mut self, program: &str, args: &[&str]) -> Result<()>; +} + +pub struct RealRunner; + +impl Runner for RealRunner { + fn run(&mut self, program: &str, args: &[&str]) -> Result<()> { + sys::run_ok(program, args).map(|_| ()) + } +} + +pub struct Zram { + root: PathBuf, +} + +impl Zram { + pub fn new(root: impl Into) -> Self { + Zram { root: root.into() } + } + + fn p(&self, rel: &str) -> PathBuf { + self.root.join(rel.trim_start_matches('/')) + } + + fn dev(&self, file: &str) -> PathBuf { + self.p(&format!("/sys/block/zram0/{file}")) + } + + fn swap(&self) -> Option { + let text = fs::read_to_string(self.p("/proc/swaps")).ok()?; + parse_swaps(&text).into_iter().find(|e| e.path == DEVICE) + } + + /// The module provides /sys/block/zram0; load it when it is missing. + fn ensure_device(&self, runner: &mut dyn Runner, wait: Duration) -> Result<()> { + if self.dev("disksize").exists() { + return Ok(()); + } + runner + .run("modprobe", &["zram"]) + .map_err(|e| Error::Unsupported(format!("this kernel has no zram module ({e})")))?; + for _ in 0..20 { + if self.dev("disksize").exists() { + return Ok(()); + } + sleep(wait); + } + Err(Error::Unsupported( + "the zram module is loaded but /sys/block/zram0 did not appear".into(), + )) + } + + pub fn on(&self, runner: &mut dyn Runner, size: Option, wait: Duration) -> Result { + if let Some(swap) = self.swap() { + return Ok(format!( + "zram swap is already on ({})", + human_bytes(swap.size_kib * 1024) + )); + } + self.ensure_device(runner, wait)?; + let bytes = match size { + Some(b) => b, + None => { + let meminfo = sys::read(self.p("/proc/meminfo"))?; + auto_size(mem_total_kib(&meminfo).ok_or_else(|| { + Error::other("cannot read the memory size from /proc/meminfo") + })?) + } + }; + // A configured but unused device must be reset before it can change. + if sys::read_trimmed(self.dev("disksize")).is_some_and(|s| s != "0") { + sys::write(self.dev("reset"), "1")?; + } + let mut note = String::new(); + let algorithms = sys::read_trimmed(self.dev("comp_algorithm")).unwrap_or_default(); + if parse_algorithms(&algorithms).0.iter().any(|a| a == "zstd") { + sys::write(self.dev("comp_algorithm"), "zstd")?; + } else { + note = " (zstd is not available: using the kernel's default compression)".into(); + } + sys::write(self.dev("disksize"), &bytes.to_string())?; + runner.run("mkswap", &["-U", "clear", DEVICE])?; + runner.run("swapon", &["-p", "100", DEVICE])?; + + // zswap would compress the same pages a second time on their way here. + let zswap = self.p("/sys/module/zswap/parameters/enabled"); + if zswap.exists() { + let _ = fs::write(zswap, "0"); + } + // Swapping to memory needs no read-ahead. + let _ = fs::write(self.p("/proc/sys/vm/page-cluster"), "0"); + // A saved `seg tune` profile decides the swappiness; without one, + // swap to memory early, as SteamOS and Fedora do. + let tuned = tune::Fs::new(&self.root); + if tuned.saved_mode().is_some() { + tuned.reapply_memory()?; + } else { + let _ = fs::write(self.p("/proc/sys/vm/swappiness"), "100"); + } + Ok(format!( + "zram swap is on: {} of zstd-compressed memory{note}", + human_bytes(bytes) + )) + } + + pub fn off(&self, runner: &mut dyn Runner) -> Result { + let was_on = self.swap().is_some(); + if was_on { + runner.run("swapoff", &[DEVICE])?; + } + if self.dev("reset").exists() + && sys::read_trimmed(self.dev("disksize")).is_some_and(|s| s != "0") + { + sys::write(self.dev("reset"), "1")?; + } + if was_on { + // Without zram the saved profile's own swappiness applies again. + tune::Fs::new(&self.root).reapply_memory()?; + Ok("zram swap is off and its memory is freed".into()) + } else { + Ok("zram swap is already off".into()) + } + } + + pub fn status(&self) -> String { + if !self.dev("disksize").exists() { + return "zram: off (the module is not loaded; `sudo seg zram on` loads it)\n".into(); + } + let Some(swap) = self.swap() else { + return "zram: off\n".into(); + }; + let mut out = format!( + "zram: on\n device {DEVICE}\n size {}\n swap used {}\n priority {}\n", + human_bytes(swap.size_kib * 1024), + human_bytes(swap.used_kib * 1024), + swap.priority + ); + if let Some(text) = sys::read_trimmed(self.dev("comp_algorithm")) { + out.push_str(&format!( + " compression {}\n", + parse_algorithms(&text).1.unwrap_or_else(|| "?".into()) + )); + } + if let Some((orig, compressed, memory)) = + sys::read_trimmed(self.dev("mm_stat")).and_then(|t| parse_mm_stat(&t)) + { + if compressed > 0 { + out.push_str(&format!( + " stored {} as {} ({:.1}:1), {} of memory\n", + human_bytes(orig), + human_bytes(compressed), + orig as f64 / compressed as f64, + human_bytes(memory) + )); + } else { + out.push_str(" stored nothing yet\n"); + } + } + out + } +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut action = None; + let mut size = None; + while let Some(arg) = parser.next()? { + match arg { + Long("size") => size = Some(parse_size(&parser.value()?.string()?)?), + Short('h') | Long("help") => { + out!("{HELP}"); + std::process::exit(0); + } + Value(v) if action.is_none() => { + action = Some(match v.string()?.as_str() { + "on" => Action::On, + "off" => Action::Off, + "status" => Action::Status, + other => { + return Err(Error::Usage(format!( + "unknown zram command `{other}` (on, off or status)" + ))); + } + }); + } + other => return Err(other.unexpected().into()), + } + } + Ok(Options { + action: action.unwrap_or(Action::Status), + size, + }) +} + +pub fn run(o: Options) -> Result<()> { + let zram = Zram::new("/"); + match o.action { + Action::Status => out!("{}", zram.status()), + Action::On => { + sys::require_root("`seg zram on`")?; + outln!( + "{}", + zram.on(&mut RealRunner, o.size, Duration::from_millis(100))? + ); + } + Action::Off => { + sys::require_root("`seg zram off`")?; + outln!("{}", zram.off(&mut RealRunner)?); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + #[derive(Default)] + struct Recorder(Vec); + + impl Runner for Recorder { + fn run(&mut self, program: &str, args: &[&str]) -> Result<()> { + self.0.push(format!("{program} {}", args.join(" "))); + Ok(()) + } + } + + const NO_WAIT: Duration = Duration::ZERO; + + fn fake(disksize: &str, algos: &str) -> TempDir { + let t = TempDir::new(); + t.put("sys/block/zram0/disksize", disksize); + t.put("sys/block/zram0/comp_algorithm", algos); + t.put("sys/block/zram0/reset", ""); + t.put( + "proc/meminfo", + "MemTotal: 8000000 kB\nMemFree: 1 kB\n", + ); + t.put("proc/swaps", "Filename Type Size Used Priority\n"); + t.put("proc/sys/vm/swappiness", "60\n"); + t.put("proc/sys/vm/page-cluster", "3\n"); + t.put("sys/module/zswap/parameters/enabled", "Y\n"); + t + } + + fn read(t: &TempDir, rel: &str) -> String { + fs::read_to_string(t.path().join(rel)) + .unwrap() + .trim() + .to_string() + } + + #[test] + fn sizes() { + assert_eq!(parse_size("2G").unwrap(), 2 << 30); + assert_eq!(parse_size("4096M").unwrap(), 4096 << 20); + assert_eq!(parse_size("512m").unwrap(), 512 << 20); + assert_eq!(parse_size("1048576").unwrap(), 1_048_576); + for bad in [ + "", + "G", + "0", + "0M", + "-1G", + "1.5G", + "2T", + "99999999999999999999G", + ] { + assert!(parse_size(bad).is_err(), "{bad}"); + } + } + + #[test] + fn half_the_ram_up_to_8_gib() { + assert_eq!(auto_size(8_000_000), 8_000_000 * 1024 / 2); + assert_eq!(auto_size(4 * 1024 * 1024), 2 << 30); + assert_eq!(auto_size(64 * 1024 * 1024), 8 << 30); + assert_eq!(auto_size(0), 0); + assert_eq!(mem_total_kib("MemTotal: 42 kB\n"), Some(42)); + assert_eq!(mem_total_kib("nothing"), None); + } + + #[test] + fn swap_table_and_stats() { + let swaps = "Filename\tType\tSize\tUsed\tPriority\n/dev/zram0 partition 4194300 1024 100\n/swapfile file 2097148 0 -2\n"; + let list = parse_swaps(swaps); + assert_eq!(list.len(), 2); + assert_eq!( + list[0], + SwapEntry { + path: "/dev/zram0".into(), + size_kib: 4_194_300, + used_kib: 1024, + priority: 100 + } + ); + assert_eq!(list[1].priority, -2); + assert!(parse_swaps("Filename Type Size Used Priority\n").is_empty()); + + assert_eq!( + parse_algorithms("lzo lzo-rle lz4 [zstd]"), + ( + vec!["lzo".into(), "lzo-rle".into(), "lz4".into(), "zstd".into()], + Some("zstd".into()) + ) + ); + assert_eq!( + parse_mm_stat("40960 10240 16384 0 16384 0 0 0 0"), + Some((40960, 10240, 16384)) + ); + assert_eq!(parse_mm_stat("1 2"), None); + assert_eq!(parse_mm_stat("a b c"), None); + } + + #[test] + fn on_configures_zstd_half_the_ram_and_swaps_to_it() { + let t = fake("0\n", "lzo lzo-rle [lz4] zstd\n"); + let mut r = Recorder::default(); + let msg = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + assert!(msg.starts_with("zram swap is on: "), "{msg}"); + assert_eq!(read(&t, "sys/block/zram0/comp_algorithm"), "zstd"); + assert_eq!( + read(&t, "sys/block/zram0/disksize"), + (8_000_000u64 * 1024 / 2).to_string() + ); + assert_eq!( + r.0, + ["mkswap -U clear /dev/zram0", "swapon -p 100 /dev/zram0"] + ); + assert_eq!(read(&t, "sys/module/zswap/parameters/enabled"), "0"); + assert_eq!(read(&t, "proc/sys/vm/page-cluster"), "0"); + // No saved `seg tune` profile: swap to memory early. + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "100"); + } + + #[test] + fn on_uses_an_explicit_size_and_resets_a_configured_device() { + let t = fake("1048576\n", "[lzo] zstd\n"); + let mut r = Recorder::default(); + Zram::new(t.path()) + .on(&mut r, Some(2 << 30), NO_WAIT) + .unwrap(); + assert_eq!(read(&t, "sys/block/zram0/reset"), "1"); + assert_eq!( + read(&t, "sys/block/zram0/disksize"), + (2u64 << 30).to_string() + ); + } + + #[test] + fn on_falls_back_when_the_kernel_lacks_zstd() { + let t = fake("0\n", "[lzo] lzo-rle lz4\n"); + let mut r = Recorder::default(); + let msg = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + assert!(msg.contains("zstd is not available"), "{msg}"); + assert_eq!( + read(&t, "sys/block/zram0/comp_algorithm"), + "[lzo] lzo-rle lz4" + ); + } + + #[test] + fn on_when_already_on_does_nothing() { + let t = fake("0\n", "[zstd]\n"); + t.put( + "proc/swaps", + "Filename Type Size Used Priority\n/dev/zram0 partition 4194300 0 100\n", + ); + let mut r = Recorder::default(); + let msg = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + assert!(msg.starts_with("zram swap is already on"), "{msg}"); + assert!(r.0.is_empty()); + } + + #[test] + fn on_loads_the_module_when_the_device_is_missing() { + let t = TempDir::new(); + t.put("proc/meminfo", "MemTotal: 1000 kB\n"); + t.put("proc/swaps", "Filename Type Size Used Priority\n"); + let mut r = Recorder::default(); + // The recorder does not create the device, so this gives up after + // asking for the module. + let e = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap_err(); + assert_eq!(r.0, ["modprobe zram"]); + assert!(matches!(e, Error::Unsupported(_)), "{e}"); + } + + #[test] + fn a_saved_tune_profile_decides_the_swappiness() { + let t = fake("0\n", "[zstd]\n"); + t.put("etc/seg/tune.conf", "mode = performance\n"); + t.put("proc/sys/vm/vfs_cache_pressure", "100\n"); + t.put("proc/sys/vm/dirty_ratio", "20\n"); + t.put("proc/sys/vm/dirty_background_ratio", "10\n"); + let mut r = Recorder::default(); + Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + // The recorder cannot add the swap, so the profile sees no zram and + // wants its own value; what matters is that tune decided, not 100. + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "10"); + } + + #[test] + fn off_swaps_off_and_resets() { + let t = fake("4294967296\n", "[zstd]\n"); + t.put( + "proc/swaps", + "Filename Type Size Used Priority\n/dev/zram0 partition 4194300 0 100\n", + ); + let mut r = Recorder::default(); + let msg = Zram::new(t.path()).off(&mut r).unwrap(); + assert_eq!(r.0, ["swapoff /dev/zram0"]); + assert_eq!(read(&t, "sys/block/zram0/reset"), "1"); + assert!(msg.contains("off"), "{msg}"); + + let t = fake("0\n", "[zstd]\n"); + let mut r = Recorder::default(); + assert_eq!( + Zram::new(t.path()).off(&mut r).unwrap(), + "zram swap is already off" + ); + assert!(r.0.is_empty()); + } + + #[test] + fn status_reports_the_ratio() { + let t = fake("4294967296\n", "lzo [zstd]\n"); + t.put( + "proc/swaps", + "Filename Type Size Used Priority\n/dev/zram0 partition 4194300 2048 100\n", + ); + t.put( + "sys/block/zram0/mm_stat", + "4194304 1048576 1310720 0 1310720 0 0 0 0\n", + ); + let text = Zram::new(t.path()).status(); + assert!(text.starts_with("zram: on"), "{text}"); + assert!(text.contains("zstd")); + assert!(text.contains("4.0 MiB as 1.0 MiB (4.0:1)"), "{text}"); + + let t = fake("0\n", "[zstd]\n"); + assert_eq!(Zram::new(t.path()).status(), "zram: off\n"); + let t = TempDir::new(); + assert!( + Zram::new(t.path()) + .status() + .contains("module is not loaded") + ); + } +} From 593b379c0b7d75c8c620d4ba7c3a61543edd4128 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:54:18 +0000 Subject: [PATCH 04/23] feat(seg): segctl package, services and image wiring - pkg/segctl: PKGBUILD, install script (enables seg-zram and seg-tune, choosing the init by what is installed, so it works in the build's chroot), Makefile. Ships seg, the services for OpenRC, runit and dinit, the balanced profile and the weekly fstrim job. - build-in-container.sh builds it from tools/seg into the same local [seglinux] repository; both profiles list segctl; check-packages.sh and online-packages.sh know it is built locally. - Setup enables seg-zram and seg-tune on every installation, and no longer cupsd (installed, not started). - zram-setup (run by udev at module load) hands over to seg zram on when seg is installed, so systems updated before segctl keep their zram. - Fixes a stale path in a comment of the fstab code, and the swap partition test now looks for the fstab line instead of the word zram. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- crates/seglinux-setup/src/answer.rs | 3 + crates/seglinux-setup/src/disk.rs | 3 +- crates/seglinux-setup/src/plan.rs | 3 +- data/system/zram-setup | 7 ++ iso/iso-profiles/seglinux-lite/profile.yaml | 2 + iso/iso-profiles/seglinux/profile.yaml | 4 ++ iso/scripts/build-in-container.sh | 21 +++++- iso/scripts/check-packages.sh | 6 +- iso/scripts/online-packages.sh | 4 +- pkg/segctl/Makefile | 16 +++++ pkg/segctl/PKGBUILD | 78 +++++++++++++++++++++ pkg/segctl/segctl.install | 25 +++++++ 12 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 pkg/segctl/Makefile create mode 100644 pkg/segctl/PKGBUILD create mode 100644 pkg/segctl/segctl.install diff --git a/crates/seglinux-setup/src/answer.rs b/crates/seglinux-setup/src/answer.rs index bcf3541..3b002a2 100644 --- a/crates/seglinux-setup/src/answer.rs +++ b/crates/seglinux-setup/src/answer.rs @@ -339,6 +339,9 @@ pub const DEFAULT_SERVICES: &[&str] = &[ "ntpd", "acpid", "power-profiles-daemon", + // From the segctl package: zram swap and the saved `seg tune` profile. + "seg-zram", + "seg-tune", ]; /// Groups every user joins (missing ones are skipped). diff --git a/crates/seglinux-setup/src/disk.rs b/crates/seglinux-setup/src/disk.rs index d65e6b6..ae9b85e 100644 --- a/crates/seglinux-setup/src/disk.rs +++ b/crates/seglinux-setup/src/disk.rs @@ -270,7 +270,8 @@ impl Layout { )); } if self.zram.is_some() { - // Created and formatted by /etc/udev/rules.d/99-seglinux-zram.rules. + // Created and formatted at boot: the udev rule 60-seglinux-zram.rules runs + // seg zram on (the seg-zram service does too). out.push_str("/dev/zram0 none swap defaults,discard,pri=100 0 0\n"); } out diff --git a/crates/seglinux-setup/src/plan.rs b/crates/seglinux-setup/src/plan.rs index caea182..1f1295d 100644 --- a/crates/seglinux-setup/src/plan.rs +++ b/crates/seglinux-setup/src/plan.rs @@ -1281,7 +1281,8 @@ mod tests { "{text}" ); assert!(text.contains("/dev/vda4")); - assert!(!text.contains("zram")); + // No zram line in the fstab (the seg-zram service is on either way). + assert!(!text.contains("/dev/zram0"), "{text}"); assert!( text.contains( "GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog nmi_watchdog=0\"" diff --git a/data/system/zram-setup b/data/system/zram-setup index 26d29e4..8d5396c 100755 --- a/data/system/zram-setup +++ b/data/system/zram-setup @@ -4,8 +4,15 @@ # nothing is written to the disk. Half the memory, at most 8 GB; zstd # usually fits it in a quarter of that. A swap partition or file stays, for # what zram cannot hold. +# +# seg (the segctl package) does the work when it is installed: `seg zram on` +# is the same thing, and the seg-zram service runs it too. The rest of this +# file is for systems that were updated to this script before segctl. set -eu PATH=/usr/bin:/usr/sbin:/bin:/sbin +if command -v seg >/dev/null 2>&1; then + exec seg zram on +fi dev=/dev/zram0 sys=/sys/block/zram0 # Already in use (a second run, or set up by hand): leave it. diff --git a/iso/iso-profiles/seglinux-lite/profile.yaml b/iso/iso-profiles/seglinux-lite/profile.yaml index 87b1676..62193df 100644 --- a/iso/iso-profiles/seglinux-lite/profile.yaml +++ b/iso/iso-profiles/seglinux-lite/profile.yaml @@ -25,6 +25,8 @@ rootfs: - seglinux # The seglinux package brings the boot menu theme; buildiso-hooks.sh # puts it on the image in place of Artix's. + # seg: zram swap, tuning and checks (tools/seg); also built locally. + - segctl - labwc - gtk4 - gtk4-layer-shell diff --git a/iso/iso-profiles/seglinux/profile.yaml b/iso/iso-profiles/seglinux/profile.yaml index af8885a..337abea 100644 --- a/iso/iso-profiles/seglinux/profile.yaml +++ b/iso/iso-profiles/seglinux/profile.yaml @@ -37,6 +37,10 @@ rootfs: - seglinux # The seglinux package brings the boot menu theme; buildiso-hooks.sh # puts it on the image in place of Artix's. + # seg: services on any init, cleaning, tuning, zram swap and checks. It + # enables the seg-zram and seg-tune services. Built from tools/seg into + # the same local repository. + - segctl # Chaotic-AUR's key and mirrors: Brave and its updates come from there # (see iso/scripts/chaotic-aur.sh). diff --git a/iso/scripts/build-in-container.sh b/iso/scripts/build-in-container.sh index 130818d..577d898 100755 --- a/iso/scripts/build-in-container.sh +++ b/iso/scripts/build-in-container.sh @@ -9,8 +9,8 @@ # PACKAGE_ONLY=true stops after the package (OUT_DIR/packages/): the update # the SegLinux update workflow publishes. # -# Steps: build the seglinux package from this checkout into a local pacman -# repository, point artools at it, check the package lists, run buildiso +# Steps: build the seglinux and segctl packages from this checkout into a +# local pacman repository, point artools at it, check the package lists, run buildiso # and copy the ISO (renamed seglinux-*.iso or seglinux-lite-*.iso) plus its # checksum to OUT_DIR. set -euo pipefail @@ -92,6 +92,23 @@ if [[ ${PACKAGE_ONLY:-false} == true ]]; then exit 0 fi +step "Building the segctl package" +# seg, from tools/seg: a package of its own that goes into the same local +# repository (the update workflow publishes only the seglinux package). +segctl_version=$(sed -n 's/^pkgver=//p' "$repo/pkg/segctl/PKGBUILD") +segwork=/var/tmp/segctl-pkg +rm -rf "$segwork" +mkdir -p "$segwork" +cp "$repo/pkg/segctl/PKGBUILD" "$repo/pkg/segctl/segctl.install" "$segwork/" +tar -C "$repo" --exclude=./tools/seg/target \ + --transform "s,^\./tools/seg,segctl-$segctl_version/tools/seg," \ + -czf "$segwork/segctl-$segctl_version.tar.gz" ./tools/seg +chown -R "$build_user:" "$segwork" +(cd "$segwork" && sudo -H -u "$build_user" env CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-2}" \ + ${RUSTUP_HOME:+"RUSTUP_HOME=$RUSTUP_HOME"} makepkg -f --noconfirm) +cp "$segwork"/segctl-*.pkg.tar.zst "$local_repo/" +repo-add -q "$local_repo/seglinux.db.tar.gz" "$local_repo"/segctl-*.pkg.tar.zst + step "Configuring artools" conf="iso-x86_64.conf" # Use one explicit config location under both sudo and pkexec. artools diff --git a/iso/scripts/check-packages.sh b/iso/scripts/check-packages.sh index 0661c69..55a9c57 100755 --- a/iso/scripts/check-packages.sh +++ b/iso/scripts/check-packages.sh @@ -67,10 +67,10 @@ cp "$conf" "$work_conf" skip=() if [[ ! -f $local_repo/seglinux.db ]]; then - # The seglinux package is built during the ISO build; when checking - # before that, leave the local repository out. + # The seglinux and segctl packages are built during the ISO build; when + # checking before that, leave the local repository out. awk '/^\[seglinux\]/{skip=1; next} /^\[/{skip=0} !skip' "$conf" > "$work_conf" - skip=(seglinux) + skip=(seglinux segctl) echo "note: $local_repo has no repository yet; not checking: ${skip[*]}" fi diff --git a/iso/scripts/online-packages.sh b/iso/scripts/online-packages.sh index 9986bcc..2b6edae 100755 --- a/iso/scripts/online-packages.sh +++ b/iso/scripts/online-packages.sh @@ -46,5 +46,5 @@ lite=$(rootfs "$profiles/seglinux-lite/common.yaml" "$profiles/seglinux-lite/pro echo "# Packages SegLinux Setup downloads when it installs the Lite edition:" echo "# the full edition's ($init, $kernel) minus what the Lite image has." -# seglinux itself is built locally and already on the image. -comm -23 <(echo "$full") <(echo "$lite") | grep -vx seglinux +# seglinux and segctl are built locally and already on the image. +comm -23 <(echo "$full") <(echo "$lite") | grep -vxE 'seglinux|segctl' || true diff --git a/pkg/segctl/Makefile b/pkg/segctl/Makefile new file mode 100644 index 0000000..49bd942 --- /dev/null +++ b/pkg/segctl/Makefile @@ -0,0 +1,16 @@ +# Build the segctl package from the working tree on an Arch/Artix host: +# make -C pkg/segctl package +VERSION := $(shell sed -n 's/^pkgver=//p' PKGBUILD) +ROOT := $(abspath ../..) + +source: + tar -C $(ROOT) --exclude=./tools/seg/target \ + --transform 's,^\./tools/seg,segctl-$(VERSION)/tools/seg,' -czf segctl-$(VERSION).tar.gz ./tools/seg + +package: source + makepkg -f --noconfirm + +clean: + rm -rf src pkg segctl-*.tar.gz *.pkg.tar.* + +.PHONY: source package clean diff --git a/pkg/segctl/PKGBUILD b/pkg/segctl/PKGBUILD new file mode 100644 index 0000000..63dd517 --- /dev/null +++ b/pkg/segctl/PKGBUILD @@ -0,0 +1,78 @@ +# Maintainer: SegLinux contributors +# +# seg, the SegLinux system tool (tools/seg in the repository): services on +# any Artix init, cleaning, tuning, zram swap and checks. The source tarball is +# made by iso/scripts/build-in-container.sh (or by hand with +# `make -C pkg/segctl source`) from the repository's tools/seg folder. + +pkgname=segctl +pkgver=0.1.0 +pkgrel=1 +pkgdesc="seg: services, cleaning, tuning, zram swap and health checks for SegLinux, on OpenRC, runit, dinit and s6" +arch=('x86_64') +url="https://github.com/SegMind25/SegLinux" +# No license has been chosen for the SegLinux repository yet. +license=('LicenseRef-SegLinux-unspecified') +# util-linux: mkswap, swapon, swapoff, dmesg, fstrim, logger and ionice; +# kmod: modprobe for the zram module; coreutils: df. +depends=('gcc-libs' 'glibc' 'util-linux' 'kmod' 'coreutils') +makedepends=('cargo') +optdepends=( + 'cronie: the weekly fstrim job (/etc/cron.d/seg-fstrim)' + 'pacman: seg clean and seg doctor' +) +# The profile the user chose, and the sysctl values it wrote. +backup=( + 'etc/seg/tune.conf' + 'etc/sysctl.d/99-seg.conf' +) +install=segctl.install +options=('!lto') +source=("$pkgname-$pkgver.tar.gz") +sha256sums=('SKIP') + +prepare() { + cd "$pkgname-$pkgver/tools/seg" + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" +} + +build() { + cd "$pkgname-$pkgver/tools/seg" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo build --frozen --release +} + +check() { + cd "$pkgname-$pkgver/tools/seg" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo test --frozen --release +} + +package() { + cd "$pkgname-$pkgver/tools/seg" + + install -Dm755 target/release/seg "$pkgdir/usr/bin/seg" + + # The seg-tune and seg-zram services for each init the project builds + # (they do nothing on one that is not installed). + install -Dm755 -t "$pkgdir/etc/init.d" dist/openrc/seg-tune dist/openrc/seg-zram + local svc + for svc in seg-tune seg-zram; do + install -Dm755 "dist/runit/$svc/run" "$pkgdir/etc/runit/sv/$svc/run" + done + install -Dm755 dist/runit/seg-zram/finish "$pkgdir/etc/runit/sv/seg-zram/finish" + install -Dm644 -t "$pkgdir/etc/dinit.d" dist/dinit/seg-tune dist/dinit/seg-zram + + # Defaults: the balanced profile. + install -Dm644 dist/tune.conf "$pkgdir/etc/seg/tune.conf" + install -Dm644 dist/99-seg.conf "$pkgdir/etc/sysctl.d/99-seg.conf" + + # TRIM for SSDs once a week. + install -Dm755 dist/fstrim-weekly "$pkgdir/usr/lib/seg/fstrim-weekly" + install -Dm644 dist/cron-fstrim "$pkgdir/etc/cron.d/seg-fstrim" + + install -Dm644 README.md "$pkgdir/usr/share/doc/seg/README.md" +} diff --git a/pkg/segctl/segctl.install b/pkg/segctl/segctl.install new file mode 100644 index 0000000..55c5e66 --- /dev/null +++ b/pkg/segctl/segctl.install @@ -0,0 +1,25 @@ +# Start the zram swap and the tuning profile at every boot. +# +# This may run in a chroot (the image build) whose init is not the host's, so +# the init is chosen by what is installed here (--offline), not by PID 1. +enable_services() { + command -v seg >/dev/null 2>&1 || return 0 + local svc + for svc in seg-zram seg-tune; do + seg svc --offline enable "$svc" >/dev/null 2>&1 || true + done +} + +post_install() { + enable_services +} + +# Not on upgrade: someone who disabled a service keeps it disabled. + +pre_remove() { + command -v seg >/dev/null 2>&1 || return 0 + local svc + for svc in seg-tune seg-zram; do + seg svc --offline disable "$svc" >/dev/null 2>&1 || true + done +} From 61e3e6c257e1f7467586a8f4668c587b436a8993 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:54:30 +0000 Subject: [PATCH 05/23] perf: makepkg builds with every thread; I/O scheduler rule skips partitions - /etc/makepkg.conf.d/10-seglinux.conf sets MAKEFLAGS="-j$(nproc)" (the generic x86-64 code generation stays, no -march=native). - udev-ioschedulers.rules: the KERNEL globs (sd[a-z]*, nvme[0-9]*n[0-9]*) also matched partitions, which have no queue/scheduler to write, so udev logged an error for every partition. Only whole disks are matched now, and virtual disks (vd*, xvd*) get none like seg tune sets, since their host does the scheduling. Checked with udevadm verify. pacman.conf already has Color and ParallelDownloads = 5. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- data/system/makepkg-seglinux.conf | 4 ++++ data/system/udev-ioschedulers.rules | 20 ++++++++++++++------ packaging/seglinux/PKGBUILD | 4 +++- 3 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 data/system/makepkg-seglinux.conf diff --git a/data/system/makepkg-seglinux.conf b/data/system/makepkg-seglinux.conf new file mode 100644 index 0000000..1536f28 --- /dev/null +++ b/data/system/makepkg-seglinux.conf @@ -0,0 +1,4 @@ +# SegLinux: build with every CPU thread (read after /etc/makepkg.conf). +# The generic x86-64 code generation is kept (no -march=native), so packages +# built here also run on other computers. +MAKEFLAGS="-j$(nproc)" diff --git a/data/system/udev-ioschedulers.rules b/data/system/udev-ioschedulers.rules index f44adac..efd03ed 100644 --- a/data/system/udev-ioschedulers.rules +++ b/data/system/udev-ioschedulers.rules @@ -1,6 +1,14 @@ -# I/O schedulers for low latency while games stream assets: -# NVMe drives schedule themselves, other SSDs use mq-deadline and -# spinning disks use BFQ, which keeps the desktop responsive. -ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*", ATTR{queue/scheduler}="none" -ACTION=="add|change", KERNEL=="sd[a-z]*|mmcblk[0-9]*", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline" -ACTION=="add|change", KERNEL=="sd[a-z]*", ATTR{queue/rotational}=="1", ATTR{queue/scheduler}="bfq" +# I/O schedulers for low latency while games stream assets (seg tune picks +# the same ones): NVMe drives and virtual disks leave scheduling to their +# controller or host, other SSDs use mq-deadline and spinning disks use +# BFQ, which keeps the desktop responsive. Only whole disks have a queue: +# partitions (nvme0n1p1, sda1) are skipped. +SUBSYSTEM!="block", GOTO="seglinux_ioschedulers_end" +ENV{DEVTYPE}!="disk", GOTO="seglinux_ioschedulers_end" +ACTION!="add|change", GOTO="seglinux_ioschedulers_end" + +KERNEL=="nvme[0-9]*n[0-9]*|vd[a-z]|xvd[a-z]", ATTR{queue/scheduler}="none" +KERNEL=="sd[a-z]|sd[a-z][a-z]|mmcblk[0-9]", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline" +KERNEL=="sd[a-z]|sd[a-z][a-z]", ATTR{queue/rotational}=="1", ATTR{queue/scheduler}="bfq" + +LABEL="seglinux_ioschedulers_end" diff --git a/packaging/seglinux/PKGBUILD b/packaging/seglinux/PKGBUILD index b4eead0..f23cf37 100644 --- a/packaging/seglinux/PKGBUILD +++ b/packaging/seglinux/PKGBUILD @@ -6,7 +6,7 @@ pkgname=seglinux pkgver=0.1.0 -pkgrel=8 +pkgrel=9 pkgdesc="SegLinux: lightweight Rust desktop, Windows program and game launcher, graphical installer and gaming tuning" arch=('x86_64') url="https://github.com/SegMind25/SegLinux" @@ -51,6 +51,7 @@ backup=( 'etc/binfmt.d/wine.conf' 'etc/sysctl.d/80-seglinux-gaming.conf' 'etc/sysctl.d/81-seglinux-network.conf' + 'etc/makepkg.conf.d/10-seglinux.conf' 'etc/xdg/xdg-desktop-portal-wlr/config' 'etc/gamemode.ini' 'etc/security/limits.d/90-seglinux-esync.conf' @@ -165,6 +166,7 @@ package() { install -Dm644 "$data/system/modules-zram.conf" "$pkgdir/usr/lib/modules-load.d/seglinux-zram.conf" install -Dm644 "$data/system/udev-zram.rules" "$pkgdir/usr/lib/udev/rules.d/60-seglinux-zram.rules" install -Dm755 "$data/system/zram-setup" "$pkgdir/usr/lib/seglinux/zram-setup" + install -Dm644 "$data/system/makepkg-seglinux.conf" "$pkgdir/etc/makepkg.conf.d/10-seglinux.conf" install -Dm644 "$data/desktop/xdpw-config" "$pkgdir/etc/xdg/xdg-desktop-portal-wlr/config" install -Dm644 "$data/system/udev-ntsync.rules" "$pkgdir/usr/lib/udev/rules.d/70-seglinux-ntsync.rules" From 775db511308ab46ba91da70dd1d92ea9034f757f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:54:30 +0000 Subject: [PATCH 06/23] feat: ripgrep, fd, eza and zoxide in the full edition Rust command-line tools from the Artix repositories (no AUR), chosen because each is small and replaces a daily habit: - ripgrep (rg) searches folders far faster than grep -r and skips what .gitignore skips; fd finds files by name without find's syntax. - eza is ls with colors and git status (aliased as ll and la only); zoxide gives z, which jumps to the folder you use most. They add a few MiB (the next build report gives the exact figure) against the 438 MiB the earlier commits take off (linux-zen-headers 325.1 MiB and opencv 112.8 MiB, from the 19 October build report, plus the X server). Not added, with reasons in docs/lean-audit.md: bat, dust, bottom, procs and sd (they replace nothing that is installed), helix (a runtime of grammars next to nano), alacritty (the terminal is the maintainers' call), zellij, sudo-rs. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- data/system/rust-tools.bashrc | 12 ++++++++++++ iso/iso-profiles/seglinux/profile.yaml | 6 ++++++ packaging/seglinux/PKGBUILD | 2 ++ 3 files changed, 20 insertions(+) create mode 100644 data/system/rust-tools.bashrc diff --git a/data/system/rust-tools.bashrc b/data/system/rust-tools.bashrc new file mode 100644 index 0000000..f9e804c --- /dev/null +++ b/data/system/rust-tools.bashrc @@ -0,0 +1,12 @@ +# SegLinux: conveniences for the Rust command-line tools, when they are +# installed (ripgrep and fd need none). Aliases only for new names: ls, cd, +# grep and find keep working as everyone expects, in scripts and out. +[[ $- == *i* ]] || return 0 +if command -v eza >/dev/null; then + alias ll='eza -l --group-directories-first' + alias la='eza -la --group-directories-first' +fi +if command -v zoxide >/dev/null; then + # `z folder` jumps to the folder you use most that matches. + eval "$(zoxide init bash)" +fi diff --git a/iso/iso-profiles/seglinux/profile.yaml b/iso/iso-profiles/seglinux/profile.yaml index 337abea..4405251 100644 --- a/iso/iso-profiles/seglinux/profile.yaml +++ b/iso/iso-profiles/seglinux/profile.yaml @@ -80,6 +80,12 @@ rootfs: - discover # Microsoft Store - flatpak + # Command-line tools written in Rust (see docs/lean-audit.md) + - ripgrep # rg: searches folders far faster than grep -r + - fd # fd: find with sane defaults + - eza # ls with colors and git status (ll, la) + - zoxide # z: cd to the folders you use most + # Accessories - dolphin # File Explorer - kitty # Terminal diff --git a/packaging/seglinux/PKGBUILD b/packaging/seglinux/PKGBUILD index f23cf37..772cb95 100644 --- a/packaging/seglinux/PKGBUILD +++ b/packaging/seglinux/PKGBUILD @@ -63,6 +63,7 @@ backup=( 'etc/xdg/mimeapps.list' 'etc/xdg/fastfetch/config.jsonc' 'etc/bash/bashrc.d/seglinux-fastfetch.bashrc' + 'etc/bash/bashrc.d/seglinux-rust-tools.bashrc' ) # The project was called Wincraft before. provides=('wincraft') @@ -184,6 +185,7 @@ package() { install -Dm644 "$data/fastfetch/config.jsonc" "$pkgdir/etc/xdg/fastfetch/config.jsonc" install -Dm644 "$data/fastfetch/logo.txt" "$pkgdir/usr/share/seglinux/fastfetch/logo.txt" install -Dm644 "$data/system/fastfetch.bashrc" "$pkgdir/etc/bash/bashrc.d/seglinux-fastfetch.bashrc" + install -Dm644 "$data/system/rust-tools.bashrc" "$pkgdir/etc/bash/bashrc.d/seglinux-rust-tools.bashrc" install -Dm755 "$data/system/apply-branding" "$pkgdir/usr/lib/seglinux/apply-branding" install -Dm755 "$data/system/pipewire-session" "$pkgdir/usr/lib/seglinux/pipewire-session" install -Dm755 "$data/system/restore-trimmed-files" "$pkgdir/usr/lib/seglinux/restore-trimmed-files" From 749fe5ea912c5a38dea6f3d3070d5d87eadc55f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:55:18 +0000 Subject: [PATCH 07/23] chore(ci): check seg, the udev rules, repeated packages and more scripts - A seg job: cargo fmt --check, clippy with -D warnings (with and without the test cfg, so the unwrap/expect ban is enforced), tests, and the release binary's size in the job summary. It fails if seg gains a dependency besides lexopt. - udevadm verify on data/system/*.rules. - A package may be listed only once per profile and init; the five repeats this found in the full profile are gone. - shellcheck also covers zram-setup (it was missing), the seg services, the weekly fstrim script, segctl.install and rust-tools.bashrc. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- .github/workflows/ci.yml | 62 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a9efbf..0214499 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,37 @@ jobs: - name: Validate the default segrun configuration run: cargo run --locked -q -p segrun -- --config data/seglinux/segrun.toml config + # seg (tools/seg) is a workspace of its own with its own release profile. + seg: + name: seg (fmt, clippy, tests, size) + runs-on: ubuntu-latest + defaults: + run: + working-directory: tools/seg + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: tools/seg + - run: cargo fmt --check + - run: cargo clippy --locked --all-targets -- -D warnings + # Without the test cfg, where unwrap and expect are compile errors. + - run: cargo clippy --locked -- -D warnings + - run: cargo test --locked + - name: Dependencies stay minimal (seg and lexopt only) + run: | + cargo tree --locked -e normal --prefix none | sort -u + test "$(cargo tree --locked -e normal --prefix none | sort -u | wc -l)" -le 2 + - name: Release binary size + run: | + cargo build --locked --release + ls -l target/release/seg + target/release/seg --version + echo "seg is $(stat -c %s target/release/seg) bytes" >> "$GITHUB_STEP_SUMMARY" + scripts: name: Shell scripts and profile files runs-on: ubuntu-latest @@ -68,12 +99,15 @@ jobs: run: | shellcheck -s bash iso/build.sh iso/scripts/*.sh packaging/seglinux/seglinux.install data/system/seglinux-upgrade data/system/seglinux-update \ packaging/make-signing-key.sh crates/seglinux-shell/tests/desktop-services.sh \ - data/system/fastfetch.bashrc + data/system/fastfetch.bashrc data/system/rust-tools.bashrc shellcheck -s sh data/system/apply-branding data/system/pipewire-session \ iso/iso-profiles/seglinux/live-overlay/usr/lib/seglinux/live-autostart \ data/desktop/seglinux-session data/desktop/seglinux-gaming-session \ data/desktop/labwc/autostart data/system/seglinux-terminal data/system/restore-trimmed-files \ - data/system/profile-gaming.sh data/greeter/seglinux-greeter-session + data/system/profile-gaming.sh data/greeter/seglinux-greeter-session data/system/zram-setup \ + tools/seg/dist/fstrim-weekly tools/seg/dist/runit/*/run tools/seg/dist/runit/seg-zram/finish \ + tools/seg/dist/openrc/seg-tune tools/seg/dist/openrc/seg-zram + shellcheck -s bash pkg/segctl/segctl.install shellcheck -s bash iso/iso-profiles/seglinux-lite/live-overlay/etc/skel/.bash_profile - name: YAML, JSON and XML syntax run: | @@ -85,6 +119,30 @@ jobs: sed '/^ *\/\//d' data/fastfetch/config.jsonc | python3 -m json.tool > /dev/null python3 -c 'import sys, xml.dom.minidom as m; [m.parse(f) for f in sys.argv[1:]]' data/desktop/labwc/rc.xml \ data/greeter/labwc/rc.xml data/greeter/labwc/menu.xml + - name: Package lists have no repeats + run: | + python3 - <<'EOF' + import collections, yaml + failed = False + for profile, common in (("seglinux", "iso/iso-profiles/common/common.yaml"), + ("seglinux-lite", "iso/iso-profiles/seglinux-lite/common.yaml")): + c = yaml.safe_load(open(common)) + p = yaml.safe_load(open(f"iso/iso-profiles/{profile}/profile.yaml")) + xorg = "packages-xlibre" if p["live-session"].get("use-xlibre") else "packages-xorg" + for init in ("openrc", "runit", "dinit"): + names = [] + for key in ("packages-base", "packages-apps", xorg, "packages-misc"): + names += c.get(key) or [] + names += (c.get("packages-init") or {}).get(init) or [] + names += p["rootfs"]["packages"] + ((p["rootfs"].get("packages-init") or {}).get(init) or []) + repeats = [n for n, k in collections.Counter(names).items() if k > 1] + if repeats: + failed = True + print(f"{profile} ({init}): listed more than once: {', '.join(repeats)}") + raise SystemExit(1 if failed else 0) + EOF + - name: udev rules + run: udevadm verify --no-style data/system/*.rules - name: Overlay links resolve run: | broken=$(find iso/iso-profiles -xtype l) From 67dc6e0321a64fcc4e43d917281a3bbfcdb97c40 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:57:20 +0000 Subject: [PATCH 08/23] docs: Rust tools, bug log, README and building notes for seg and the audit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- README.md | 6 +++++ docs/building.md | 3 ++- docs/lean-audit.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 37c04ae..1d71170 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,12 @@ published. starts it, under GameMode when it is a game. * **Fast, systemd-free base.** OpenRC by default (parallel boot), or runit or dinit; rolling release. +* **`seg`, one small tool for the system.** `seg info`, `seg svc` (the same + commands on OpenRC, runit, dinit and s6), `seg clean`, `seg tune` + (light, balanced or performance), `seg zram` and `seg doctor`, which finds + problems and prints their fixes. A single Rust binary of about 550 KiB; see + [tools/seg](tools/seg/README.md). What the images weigh and what was taken + off them: [docs/lean-audit.md](docs/lean-audit.md). ## Website diff --git a/docs/building.md b/docs/building.md index dc70747..4cf6f61 100644 --- a/docs/building.md +++ b/docs/building.md @@ -3,7 +3,8 @@ SegLinux is built with Artix Linux's own ISO tooling: `buildiso` from [artools](https://gitea.artixlinux.org/artix/artools) reads `iso/iso-profiles/seglinux/profile.yaml`. The Rust tools are packaged with -`packaging/seglinux/PKGBUILD` and served to `buildiso` from a local pacman +`packaging/seglinux/PKGBUILD` (and `seg`, the system tool, with +`pkg/segctl/PKGBUILD`) and served to `buildiso` from a local pacman repository during the build. ## Quick start (any Linux host with docker or podman) diff --git a/docs/lean-audit.md b/docs/lean-audit.md index 1fddc33..2d95e83 100644 --- a/docs/lean-audit.md +++ b/docs/lean-audit.md @@ -75,6 +75,62 @@ Protected, never touched: `base`, the kernel, `openrc` and the `-openrc` / `-runit` / `-dinit` service packages, `elogind`, `artix-keyring` (through `base`), `pacman`, `sudo`, NetworkManager, the firmware packages. +## Rust command-line tools + +Only packages from the Artix repositories; none from the AUR. Sizes are not +given because they could not be measured here (no repository access); the +build report ("What takes the space") of the next ISO shows them. + +| Tool | Added | Replaces | Why | +|---|---|---|---| +| `ripgrep` (`rg`) | full edition | `grep -r` for searching folders | far faster on a tree, skips `.gitignore`d files and binaries | +| `fd` | full edition | `find -name` | sane defaults, readable syntax | +| `eza` | full edition | `ls -l` (as the new names `ll`, `la`; `ls` itself is untouched) | colors, git status, folders first | +| `zoxide` | full edition | `cd` by typing paths (`z folder`) | learns the folders you use | +| `bat`, `dust`, `bottom`, `procs`, `sd` | no | nothing installed | they would only add to the image | +| `helix` | no | `nano` | it needs a runtime of grammars; nano is already there and small | +| `alacritty` | no | `kitty`/`foot` | the terminal is the maintainers' call (see below) | +| `zellij` | no | no multiplexer is wanted | | +| `sudo-rs` | no | `sudo` | `sudo` is protected (the live user, Setup and `artools` use it) and its availability in the Artix repositories could not be checked | +| `greetd` + `tuigreet` | no | | SegLinux already signs in through greetd with its own Rust greeter | + +They are installed in the full edition only (the Lite image stays as it +was); Lite's Setup downloads them with the rest. `ll`, `la` and `z` come from +`/etc/bash/bashrc.d/seglinux-rust-tools.bashrc`. + +## Bugs found + +File and line are those of the commit before this work (`4ca2f45`). + +| file:line | problem | fix | +|---|---|---| +| `data/system/udev-ioschedulers.rules:4-6` | the globs `nvme[0-9]*n[0-9]*` and `sd[a-z]*` also match partitions (`nvme0n1p1`, `sda1`), which have no `queue/scheduler`: udev logged an error for each. Virtual disks (`vda`) got no rule. | only whole disks (`DEVTYPE=="disk"`); `vd*`/`xvd*` get `none`; checked with `udevadm verify` | +| `iso/iso-profiles/seglinux/profile.yaml:79-82,123` | `xorg-server`, `xorg-xwayland`, `xdg-user-dirs`, `xdg-utils` and `ttf-liberation` also in `common.yaml:55,77-79,82` | listed once; CI now fails on a repeat | +| `crates/seglinux-setup/src/plan.rs:824` | the NVIDIA DKMS driver could only build because 325 MiB of kernel headers were on every image | Setup installs the running kernel's headers in that step | +| `iso/scripts/build-in-container.sh:157-160` | the artools patch that reads the kernel version without headers applied to non-stock kernels only; the stock kernel relied on `linux-headers` | applies to every kernel | +| `crates/seglinux-setup/src/disk.rs:273` | comment names `/etc/udev/rules.d/99-seglinux-zram.rules`, which does not exist (it is `/usr/lib/udev/rules.d/60-seglinux-zram.rules`) | corrected | +| `.github/workflows/ci.yml:72-76` | `data/system/zram-setup` was not shellchecked; the udev rules were not checked at all | both are now; new job for `seg` | +| `crates/seglinux-setup/src/plan.rs:1273` | the swap partition test asserted that the plan never contains the word `zram` | asserts there is no `/dev/zram0` fstab line, which is what it means | + +Checked and fine: `shellcheck` is clean on every shell script of the +repository (also with the style level on); every file the PKGBUILD installs +exists; the executable bits are right; the init-suffixed packages are in the +list of their init; there is no systemd unit anywhere; the only hard-coded +account is the live session's `seglinux` user (in `sudoers.d`, live only); +pacman.conf lists the Artix repositories first and Chaotic-AUR last, with +`SigLevel = Required DatabaseOptional`, `Color` and `ParallelDownloads = 5`. + +Found, not changed: + +* `PKGBUILD:14`: `license=('LicenseRef-SegLinux-unspecified')`: the project has + no license yet; that is the owner's decision. +* `swap = "none"` in an answer file still gets zram: the module is loaded and + the udev rule starts it whatever the answer says. +* The two greetd `config.toml` and `pacman.conf` copies in the root overlays + are identical to each other (and the first to `data/greeter/greetd.toml`). +* `seglinux-upgrade` updates only the `seglinux` package, so a system + installed before this work does not get `segctl` that way yet. + ## Decisions for the maintainers These are product choices, so they were measured and left alone: From 57a1b1f53246050244b92ab9a36cd5960f5e182a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:59:48 +0000 Subject: [PATCH 09/23] chore(ci): temporary ISO check for the work branch [iso check] Builds both editions on seg/rust-lean-overhaul without publishing anything (the ISO workflow cannot be started from here), lists what the system image contains and does not contain, and boots Lite in QEMU, logs in on a text console and runs seg. Removed again once the branch has been checked. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- .github/iso-check/inspect.sh | 36 +++++++++ .github/iso-check/qemu_check.py | 130 ++++++++++++++++++++++++++++++++ .github/workflows/iso-check.yml | 51 +++++++++++++ 3 files changed, 217 insertions(+) create mode 100755 .github/iso-check/inspect.sh create mode 100644 .github/iso-check/qemu_check.py create mode 100644 .github/workflows/iso-check.yml diff --git a/.github/iso-check/inspect.sh b/.github/iso-check/inspect.sh new file mode 100755 index 0000000..7c20c1d --- /dev/null +++ b/.github/iso-check/inspect.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Looks inside a built ISO's system image (without booting it) and prints +# what this branch changed: sizes, what is and is not installed, which +# services are enabled. Usage: inspect.sh ISO +set -uo pipefail +iso=$1 +sudo apt-get install -y -qq squashfs-tools >/dev/null +sudo mkdir -p /mnt/iso +sudo mount -o loop,ro "$iso" /mnt/iso +echo "== ISO: $(stat -c %s "$iso") bytes" +ls -l /mnt/iso/LiveOS /mnt/iso/boot | grep -E 'img|vmlinuz' +list=$(mktemp) +sudo unsquashfs -ll /mnt/iso/LiveOS/rootfs.img > "$list" 2>/dev/null +echo "== listing: $(wc -l < "$list") entries" +has() { grep -qE "squashfs-root/$1( ->.*)?\$" "$list" && echo "present $1" || echo "ABSENT $1"; } +echo "== packages in the image: $(grep -cE 'squashfs-root/var/lib/pacman/local/[^/]+/desc$' "$list")" +echo "== should be there" +for f in usr/bin/seg etc/init.d/seg-zram etc/init.d/seg-tune etc/runlevels/default/seg-zram etc/runlevels/default/seg-tune \ + etc/seg/tune.conf etc/sysctl.d/99-seg.conf etc/cron.d/seg-fstrim usr/lib/seg/fstrim-weekly \ + etc/makepkg.conf.d/10-seglinux.conf usr/lib/seglinux/zram-setup usr/lib/udev/rules.d/60-seglinux-ioschedulers.rules \ + etc/runit/sv/seg-zram/run etc/dinit.d/seg-zram; do has "$f"; done +if [[ $EDITION == full ]]; then + for f in usr/bin/rg usr/bin/fd usr/bin/eza usr/bin/zoxide etc/bash/bashrc.d/seglinux-rust-tools.bashrc usr/bin/xwayland usr/bin/Xwayland; do has "$f"; done +fi +echo "== should be gone" +for f in usr/bin/Xorg usr/bin/spectacle usr/bin/inxi; do has "$f"; done +echo "kernel header folders: $(grep -cE 'squashfs-root/usr/src/[^/]+$' "$list") (0 expected)" +echo "== enabled in the image" +grep -E 'squashfs-root/etc/runlevels/[a-z]+/[^/]+ ->' "$list" | sed -E 's#.*squashfs-root/etc/runlevels/##; s# ->.*##' | sort | tr '\n' ' ' +echo +echo "== packages that need the most room (installed size, MiB)" +sudo unsquashfs -f -d /tmp/rt /mnt/iso/LiveOS/rootfs.img var/lib/pacman/local >/dev/null 2>&1 +for d in /tmp/rt/var/lib/pacman/local/*/; do + n=$(basename "$d"); size=$(awk '/^%SIZE%/{getline; print $1}' "$d/desc"); echo "$((size / 1048576)) $n" +done | sort -rn | head -12 +echo "total installed MiB: $(for d in /tmp/rt/var/lib/pacman/local/*/; do awk '/^%SIZE%/{getline; s+=$1} END{print s+0}' "$d/desc"; done | awk '{t+=$1} END{printf "%d", t / 1048576}')" diff --git a/.github/iso-check/qemu_check.py b/.github/iso-check/qemu_check.py new file mode 100644 index 0000000..40b5914 --- /dev/null +++ b/.github/iso-check/qemu_check.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Boot an ISO headless in QEMU, log in on a text console and run seg. + +Best effort and for the log only: it drives QEMU's monitor (screendump and +sendkey), reads the screen with tesseract and prints what it saw. It always +exits 0; the image checks before it are what fail the job. + +usage: qemu_check.py ISO [boot_seconds] +""" +import os +import socket +import subprocess +import sys +import time + +from PIL import Image + +ISO = sys.argv[1] +BOOT_WAIT = int(sys.argv[2]) if len(sys.argv) > 2 else 240 +MON = "/tmp/qemu-mon.sock" +WORK = "/tmp/qemu-check" +os.makedirs(WORK, exist_ok=True) + +KEYS = {" ": "spc", "-": "minus", ".": "dot", ";": "semicolon", "/": "slash", "\n": "ret", "_": "shift-minus", "=": "equal"} + + +def log(msg): + print(msg, flush=True) + + +class Monitor: + def __init__(self, path): + deadline = time.time() + 30 + while True: + try: + self.sock = socket.socket(socket.AF_UNIX) + self.sock.connect(path) + break + except OSError: + if time.time() > deadline: + raise + time.sleep(0.5) + self.sock.settimeout(5) + self.read() + + def read(self): + data = b"" + try: + while True: + chunk = self.sock.recv(4096) + if not chunk: + break + data += chunk + if data.rstrip().endswith(b"(qemu)"): + break + except socket.timeout: + pass + return data.decode(errors="replace") + + def cmd(self, text): + self.sock.sendall(text.encode() + b"\n") + return self.read() + + def key(self, name): + self.cmd(f"sendkey {name}") + time.sleep(0.12) + + def type(self, text): + for ch in text: + self.key(KEYS.get(ch, ch)) + + def screen_text(self, tag): + ppm = f"{WORK}/{tag}.ppm" + self.cmd(f"screendump {ppm}") + time.sleep(1) + png = f"{WORK}/{tag}.png" + try: + img = Image.open(ppm) + img = img.resize((img.width * 2, img.height * 2)) + img.save(png) + out = subprocess.run(["tesseract", png, "-", "--psm", "6"], capture_output=True, text=True, timeout=60).stdout + except Exception as e: # the screen may not be written yet + return f"(no screen: {e})" + return "\n".join(line for line in out.splitlines() if line.strip()) + + +def main(): + args = ["qemu-system-x86_64", "-m", "3072", "-smp", "2", "-cdrom", ISO, "-boot", "d", + "-display", "none", "-vga", "std", "-monitor", f"unix:{MON},server,nowait", + "-netdev", "user,id=n0", "-device", "virtio-net-pci,netdev=n0"] + if os.access("/dev/kvm", os.R_OK | os.W_OK): + args += ["-enable-kvm", "-cpu", "host"] + log("qemu: using KVM") + else: + args += ["-cpu", "max"] + log("qemu: no KVM, emulating (slow)") + boot_wait = BOOT_WAIT if "-enable-kvm" in args else max(BOOT_WAIT, 420) + qemu = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + try: + mon = Monitor(MON) + start = time.time() + n = 0 + while time.time() - start < boot_wait: + time.sleep(30) + n += 1 + text = mon.screen_text(f"boot{n}") + log(f"--- screen after {int(time.time() - start)} s:\n" + "\n".join(text.splitlines()[:12])) + # Switch to the second console, where agetty waits for a login. + mon.cmd("sendkey ctrl-alt-f2") + time.sleep(5) + log("--- console 2:\n" + mon.screen_text("tty2")) + mon.type("seglinux\n") + time.sleep(4) + mon.type("seglinux\n") + time.sleep(8) + for i, command in enumerate(["seg info", "seg zram status", "seg tune", "seg svc list --enabled", "seg doctor"]): + mon.type("clear\n") + time.sleep(1) + mon.type(command + "\n") + time.sleep(12) + log(f"--- $ {command}:\n" + mon.screen_text(f"cmd{i}")) + except Exception as e: + log(f"qemu check stopped: {type(e).__name__}: {e}") + finally: + qemu.kill() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/iso-check.yml b/.github/workflows/iso-check.yml new file mode 100644 index 0000000..d939bad --- /dev/null +++ b/.github/workflows/iso-check.yml @@ -0,0 +1,51 @@ +name: ISO check + +# Builds an image on a branch WITHOUT publishing anything, looks inside it +# and boots the Lite one in QEMU. Starts on a push to the work branch whose +# commit message contains "[iso check]". Temporary: removed once the +# seg/rust-lean-overhaul branch is checked. +on: + push: + branches: [seg/rust-lean-overhaul] + +permissions: + contents: read + +jobs: + iso: + name: Build and inspect (${{ matrix.edition }}, openrc, linux-zen) + if: contains(github.event.head_commit.message, '[iso check]') + runs-on: ubuntu-latest + timeout-minutes: 150 + strategy: + fail-fast: false + matrix: + edition: [lite, full] + env: + EDITION: ${{ matrix.edition }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + sudo docker system prune -af > /dev/null + df -h / + - name: Load the loop module + run: sudo modprobe loop + - name: Build + run: | + edition=() + [[ $EDITION == lite ]] && edition=(--lite) + SEGLINUX_PKGVER="0.1.0.$(git rev-list --count origin/main)" \ + ./iso/build.sh "${edition[@]}" -e docker -i openrc -k linux-zen + - name: Inside the image + run: .github/iso-check/inspect.sh "$(ls iso/out/seglinux-*.iso)" + - name: Boot it in QEMU (Lite) + if: matrix.edition == 'lite' + continue-on-error: true + run: | + sudo apt-get install -y -qq qemu-system-x86 tesseract-ocr python3-pil >/dev/null + [[ -e /dev/kvm ]] && sudo chmod 666 /dev/kvm || true + python3 .github/iso-check/qemu_check.py "$(ls iso/out/seglinux-*.iso)" 150 From cfd6ba88e0400fc23a2a7db1905b383a7e757db6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:10:11 +0000 Subject: [PATCH 10/23] fix(seg): keep Setup's zram swappiness and leave its zram device alone Two conflicts with what SegLinux Setup already writes for zram, found while reading the code again: - Setup writes /etc/sysctl.d/90-seglinux-zram.conf with vm.swappiness = 180. seg tune raised swappiness to only 100 with zram on and its 99-seg.conf sorts after Setup's file, so installed systems would have ended up at 100. zram swap now means at least 180 in every profile. - Setup also writes /etc/udev/rules.d/99-seglinux-zram.rules, which sizes and formats zram0 (fstab switches it on). seg zram on reset a sized device, which could run under that rule's mkswap. A sized device is now kept as it is, and the package's udev rule calls seg zram on --if-unconfigured, which leaves it alone entirely, as the old script did. An explicit --size still resizes. Also puts back the comment in disk.rs that named Setup's udev rule: it was right, and the earlier "fix" of it was wrong. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- crates/seglinux-setup/src/disk.rs | 3 +- data/system/zram-setup | 9 +- docs/lean-audit.md | 1 - tools/seg/README.md | 11 ++- tools/seg/segctl/src/tune.rs | 21 +++-- tools/seg/segctl/src/zram.rs | 151 +++++++++++++++++++++++------- 6 files changed, 143 insertions(+), 53 deletions(-) diff --git a/crates/seglinux-setup/src/disk.rs b/crates/seglinux-setup/src/disk.rs index ae9b85e..d65e6b6 100644 --- a/crates/seglinux-setup/src/disk.rs +++ b/crates/seglinux-setup/src/disk.rs @@ -270,8 +270,7 @@ impl Layout { )); } if self.zram.is_some() { - // Created and formatted at boot: the udev rule 60-seglinux-zram.rules runs - // seg zram on (the seg-zram service does too). + // Created and formatted by /etc/udev/rules.d/99-seglinux-zram.rules. out.push_str("/dev/zram0 none swap defaults,discard,pri=100 0 0\n"); } out diff --git a/data/system/zram-setup b/data/system/zram-setup index 8d5396c..a0dc70d 100755 --- a/data/system/zram-setup +++ b/data/system/zram-setup @@ -6,12 +6,15 @@ # what zram cannot hold. # # seg (the segctl package) does the work when it is installed: `seg zram on` -# is the same thing, and the seg-zram service runs it too. The rest of this -# file is for systems that were updated to this script before segctl. +# is the same thing, and the seg-zram service runs it too. It leaves a device +# alone that has a size already, as this script does: on installed systems +# SegLinux Setup's own rule (/etc/udev/rules.d/99-seglinux-zram.rules) sizes +# and formats zram0, and fstab switches it on. The rest of this file is for +# systems that were updated to this script before segctl. set -eu PATH=/usr/bin:/usr/sbin:/bin:/sbin if command -v seg >/dev/null 2>&1; then - exec seg zram on + exec seg zram on --if-unconfigured fi dev=/dev/zram0 sys=/sys/block/zram0 diff --git a/docs/lean-audit.md b/docs/lean-audit.md index 2d95e83..ff406cb 100644 --- a/docs/lean-audit.md +++ b/docs/lean-audit.md @@ -108,7 +108,6 @@ File and line are those of the commit before this work (`4ca2f45`). | `iso/iso-profiles/seglinux/profile.yaml:79-82,123` | `xorg-server`, `xorg-xwayland`, `xdg-user-dirs`, `xdg-utils` and `ttf-liberation` also in `common.yaml:55,77-79,82` | listed once; CI now fails on a repeat | | `crates/seglinux-setup/src/plan.rs:824` | the NVIDIA DKMS driver could only build because 325 MiB of kernel headers were on every image | Setup installs the running kernel's headers in that step | | `iso/scripts/build-in-container.sh:157-160` | the artools patch that reads the kernel version without headers applied to non-stock kernels only; the stock kernel relied on `linux-headers` | applies to every kernel | -| `crates/seglinux-setup/src/disk.rs:273` | comment names `/etc/udev/rules.d/99-seglinux-zram.rules`, which does not exist (it is `/usr/lib/udev/rules.d/60-seglinux-zram.rules`) | corrected | | `.github/workflows/ci.yml:72-76` | `data/system/zram-setup` was not shellchecked; the udev rules were not checked at all | both are now; new job for `seg` | | `crates/seglinux-setup/src/plan.rs:1273` | the swap partition test asserted that the plan never contains the word `zram` | asserts there is no `/dev/zram0` fstab line, which is what it means | diff --git a/tools/seg/README.md b/tools/seg/README.md index 9e37d76..5eb1b60 100644 --- a/tools/seg/README.md +++ b/tools/seg/README.md @@ -77,7 +77,8 @@ Dry run unless `--apply` is given. | CPU governor (first available) | powersave, conservative, schedutil, ondemand | schedutil, ondemand, powersave | performance | | energy/performance preference | power | balance_performance | performance | -* With zram swap on, swappiness is at least 100: swapping to compressed memory +* With zram swap on, swappiness is at least 180, the value Setup writes for + zram (/etc/sysctl.d/90-seglinux-zram.conf): swapping to compressed memory is cheaper than re-reading files from disk. * I/O scheduler of every disk: `none` for NVMe and virtual disks (their controller or host schedules), `mq-deadline` for SSDs, `bfq` for spinning @@ -95,9 +96,11 @@ Dry run unless `--apply` is given. `seg zram on` loads the module if needed and makes /dev/zram0 swap: zstd, half the RAM (at most 8 GiB, because the device's table uses memory in -proportion to its size; `--size 2G` overrides), priority 100. It also turns -zswap off (it would compress the same pages twice) and sets -`vm.page-cluster=0`. `seg zram off` swaps off and frees the memory; `status` +proportion to its size; `--size 2G` overrides), priority 100. A device that +already has a size (SegLinux Setup's udev rule gives zram0 one on installed +systems) keeps it, and `--if-unconfigured`, which the package's udev rule +uses, leaves such a device entirely alone. It also turns zswap off (it would +compress the same pages twice) and sets `vm.page-cluster=0`. `seg zram off` swaps off and frees the memory; `status` shows size, use and compression ratio. The `seg-zram` service runs it at boot. ## seg doctor diff --git a/tools/seg/segctl/src/tune.rs b/tools/seg/segctl/src/tune.rs index 49a5d81..b32fe5e 100644 --- a/tools/seg/segctl/src/tune.rs +++ b/tools/seg/segctl/src/tune.rs @@ -25,13 +25,18 @@ Usage: seg tune [light|balanced|performance|apply|status] [--dry-run] status show the saved profile and the current values (default) Also sets the I/O scheduler of every disk: none for NVMe, mq-deadline for -SSDs, bfq for spinning disks. With zram swap on, swappiness is at least 100 +SSDs, bfq for spinning disks. With zram swap on, swappiness is at least 180 (compressed memory is cheaper to swap to than disk is to read from). The profile is saved in /etc/seg/tune.conf and /etc/sysctl.d/99-seg.conf. Needs root, except `status` and --dry-run. "; +/// Swappiness with zram swap on: swapping to compressed memory is cheaper +/// than re-reading files, so swap early. The value SegLinux Setup writes to +/// /etc/sysctl.d/90-seglinux-zram.conf (ArchWiki, Fedora, Pop!_OS). +pub const ZRAM_SWAPPINESS: u32 = 180; + pub const CONF: &str = "/etc/seg/tune.conf"; pub const SYSCTL_CONF: &str = "/etc/sysctl.d/99-seg.conf"; @@ -119,7 +124,7 @@ fn profile(mode: Mode) -> Profile { pub fn sysctl_values(mode: Mode, zram_active: bool) -> [(&'static str, u32); 4] { let p = profile(mode); let swappiness = if zram_active { - p.swappiness.max(100) + p.swappiness.max(ZRAM_SWAPPINESS) } else { p.swappiness }; @@ -575,21 +580,21 @@ mod tests { } #[test] - fn zram_raises_swappiness_to_at_least_100() { + fn zram_raises_swappiness_to_the_zram_value() { assert_eq!( sysctl_values(Mode::Performance, true)[0], - ("vm.swappiness", 100) + ("vm.swappiness", ZRAM_SWAPPINESS) ); assert_eq!( sysctl_values(Mode::Balanced, true)[0], - ("vm.swappiness", 100) + ("vm.swappiness", ZRAM_SWAPPINESS) ); assert_eq!( sysctl_values(Mode::Performance, false)[0], ("vm.swappiness", 10) ); // Never lowered. - assert_eq!(sysctl_values(Mode::Light, true)[0].1, 100); + assert_eq!(sysctl_values(Mode::Light, true)[0].1, ZRAM_SWAPPINESS); } #[test] @@ -821,8 +826,8 @@ mod tests { let fs_ = Fs::new(t.path()); assert!(fs_.zram_active()); fs_.apply(Mode::Performance, true, false).unwrap(); - assert_eq!(read(&t, "proc/sys/vm/swappiness"), "100"); - assert!(read(&t, "etc/sysctl.d/99-seg.conf").contains("vm.swappiness = 100")); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "180"); + assert!(read(&t, "etc/sysctl.d/99-seg.conf").contains("vm.swappiness = 180")); // zram goes away: the saved profile's own value comes back. t.put("proc/swaps", "Filename Type Size Used Priority\n"); fs_.reapply_memory().unwrap(); diff --git a/tools/seg/segctl/src/zram.rs b/tools/seg/segctl/src/zram.rs index 15ee644..536abf4 100644 --- a/tools/seg/segctl/src/zram.rs +++ b/tools/seg/segctl/src/zram.rs @@ -12,13 +12,15 @@ use crate::sys::{self, human_bytes}; use crate::tune; pub const HELP: &str = "\ -Usage: seg zram on|off|status [--size SIZE] +Usage: seg zram on|off|status [--size SIZE] [--if-unconfigured] on set up /dev/zram0 as swap: zstd, half of the RAM (at most 8G) off stop using it and free its memory status show whether it is on, its size and how well it compresses ---size SIZE size of the device instead of half the RAM: 2G, 4096M, 512M +--size SIZE size of the device instead of half the RAM: 2G, 4096M, 512M +--if-unconfigured do nothing when /dev/zram0 already has a size (set by a + udev rule: used by SegLinux's own rule, which runs at boot) `on` and `off` need root. The seg-zram service runs `seg zram on` at boot. "; @@ -34,7 +36,16 @@ pub enum Action { pub struct Options { pub action: Action, + pub on: OnOptions, +} + +#[derive(Default)] +pub struct OnOptions { + /// Size of the device instead of half the RAM. pub size: Option, + /// Do nothing when /dev/zram0 has been given a size already: udev rules + /// (SegLinux Setup writes one) and fstab set it up in that case. + pub if_unconfigured: bool, } /// `2G`, `4096M`, `512m`, `1048576` (bytes) -> bytes. @@ -173,7 +184,7 @@ impl Zram { )) } - pub fn on(&self, runner: &mut dyn Runner, size: Option, wait: Duration) -> Result { + pub fn on(&self, runner: &mut dyn Runner, opts: &OnOptions, wait: Duration) -> Result { if let Some(swap) = self.swap() { return Ok(format!( "zram swap is already on ({})", @@ -181,27 +192,37 @@ impl Zram { )); } self.ensure_device(runner, wait)?; - let bytes = match size { - Some(b) => b, - None => { - let meminfo = sys::read(self.p("/proc/meminfo"))?; - auto_size(mem_total_kib(&meminfo).ok_or_else(|| { - Error::other("cannot read the memory size from /proc/meminfo") - })?) - } - }; - // A configured but unused device must be reset before it can change. - if sys::read_trimmed(self.dev("disksize")).is_some_and(|s| s != "0") { - sys::write(self.dev("reset"), "1")?; + let configured = sys::read_trimmed(self.dev("disksize")).is_some_and(|s| s != "0"); + if configured && opts.if_unconfigured { + return Ok("zram0 is set up by another rule already; leaving it".into()); } let mut note = String::new(); - let algorithms = sys::read_trimmed(self.dev("comp_algorithm")).unwrap_or_default(); - if parse_algorithms(&algorithms).0.iter().any(|a| a == "zstd") { - sys::write(self.dev("comp_algorithm"), "zstd")?; - } else { - note = " (zstd is not available: using the kernel's default compression)".into(); + let mut bytes = sys::read_trimmed(self.dev("disksize")) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + // A device somebody sized keeps its size (and compression) unless a + // size is asked for; an unused one must be reset to change. + if !configured || opts.size.is_some() { + bytes = match opts.size { + Some(b) => b, + None => { + let meminfo = sys::read(self.p("/proc/meminfo"))?; + auto_size(mem_total_kib(&meminfo).ok_or_else(|| { + Error::other("cannot read the memory size from /proc/meminfo") + })?) + } + }; + if configured { + sys::write(self.dev("reset"), "1")?; + } + let algorithms = sys::read_trimmed(self.dev("comp_algorithm")).unwrap_or_default(); + if parse_algorithms(&algorithms).0.iter().any(|a| a == "zstd") { + sys::write(self.dev("comp_algorithm"), "zstd")?; + } else { + note = " (zstd is not available: using the kernel's default compression)".into(); + } + sys::write(self.dev("disksize"), &bytes.to_string())?; } - sys::write(self.dev("disksize"), &bytes.to_string())?; runner.run("mkswap", &["-U", "clear", DEVICE])?; runner.run("swapon", &["-p", "100", DEVICE])?; @@ -213,15 +234,18 @@ impl Zram { // Swapping to memory needs no read-ahead. let _ = fs::write(self.p("/proc/sys/vm/page-cluster"), "0"); // A saved `seg tune` profile decides the swappiness; without one, - // swap to memory early, as SteamOS and Fedora do. + // swap to memory early, as Setup's 90-seglinux-zram.conf does. let tuned = tune::Fs::new(&self.root); if tuned.saved_mode().is_some() { tuned.reapply_memory()?; } else { - let _ = fs::write(self.p("/proc/sys/vm/swappiness"), "100"); + let _ = fs::write( + self.p("/proc/sys/vm/swappiness"), + tune::ZRAM_SWAPPINESS.to_string(), + ); } Ok(format!( - "zram swap is on: {} of zstd-compressed memory{note}", + "zram swap is on: {} of compressed memory{note}", human_bytes(bytes) )) } @@ -286,10 +310,11 @@ impl Zram { pub fn parse_args(parser: &mut lexopt::Parser) -> Result { use lexopt::prelude::*; let mut action = None; - let mut size = None; + let mut on = OnOptions::default(); while let Some(arg) = parser.next()? { match arg { - Long("size") => size = Some(parse_size(&parser.value()?.string()?)?), + Long("size") => on.size = Some(parse_size(&parser.value()?.string()?)?), + Long("if-unconfigured") => on.if_unconfigured = true, Short('h') | Long("help") => { out!("{HELP}"); std::process::exit(0); @@ -311,7 +336,7 @@ pub fn parse_args(parser: &mut lexopt::Parser) -> Result { } Ok(Options { action: action.unwrap_or(Action::Status), - size, + on, }) } @@ -323,7 +348,7 @@ pub fn run(o: Options) -> Result<()> { sys::require_root("`seg zram on`")?; outln!( "{}", - zram.on(&mut RealRunner, o.size, Duration::from_millis(100))? + zram.on(&mut RealRunner, &o.on, Duration::from_millis(100))? ); } Action::Off => { @@ -440,7 +465,9 @@ mod tests { fn on_configures_zstd_half_the_ram_and_swaps_to_it() { let t = fake("0\n", "lzo lzo-rle [lz4] zstd\n"); let mut r = Recorder::default(); - let msg = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + let msg = Zram::new(t.path()) + .on(&mut r, &OnOptions::default(), NO_WAIT) + .unwrap(); assert!(msg.starts_with("zram swap is on: "), "{msg}"); assert_eq!(read(&t, "sys/block/zram0/comp_algorithm"), "zstd"); assert_eq!( @@ -454,7 +481,7 @@ mod tests { assert_eq!(read(&t, "sys/module/zswap/parameters/enabled"), "0"); assert_eq!(read(&t, "proc/sys/vm/page-cluster"), "0"); // No saved `seg tune` profile: swap to memory early. - assert_eq!(read(&t, "proc/sys/vm/swappiness"), "100"); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "180"); } #[test] @@ -462,7 +489,14 @@ mod tests { let t = fake("1048576\n", "[lzo] zstd\n"); let mut r = Recorder::default(); Zram::new(t.path()) - .on(&mut r, Some(2 << 30), NO_WAIT) + .on( + &mut r, + &OnOptions { + size: Some(2 << 30), + ..OnOptions::default() + }, + NO_WAIT, + ) .unwrap(); assert_eq!(read(&t, "sys/block/zram0/reset"), "1"); assert_eq!( @@ -471,11 +505,52 @@ mod tests { ); } + #[test] + fn a_device_another_rule_sized_is_left_alone() { + // SegLinux Setup's udev rule gives zram0 its size and formats it; the + // package's rule must not reset it under that rule's feet. + let t = fake("4294967296\n", "[zstd]\n"); + let mut r = Recorder::default(); + let msg = Zram::new(t.path()) + .on( + &mut r, + &OnOptions { + if_unconfigured: true, + ..OnOptions::default() + }, + NO_WAIT, + ) + .unwrap(); + assert!(msg.contains("leaving it"), "{msg}"); + assert!(r.0.is_empty()); + assert_eq!(read(&t, "sys/block/zram0/reset"), ""); + assert_eq!(read(&t, "sys/block/zram0/disksize"), "4294967296"); + } + + #[test] + fn a_sized_device_that_is_not_swap_keeps_its_size_for_a_plain_on() { + let t = fake("4294967296\n", "[zstd]\n"); + let mut r = Recorder::default(); + let msg = Zram::new(t.path()) + .on(&mut r, &OnOptions::default(), NO_WAIT) + .unwrap(); + assert!(msg.starts_with("zram swap is on: 4.0 GiB"), "{msg}"); + // No reset, same size; just formatted and switched on. + assert_eq!(read(&t, "sys/block/zram0/reset"), ""); + assert_eq!(read(&t, "sys/block/zram0/disksize"), "4294967296"); + assert_eq!( + r.0, + ["mkswap -U clear /dev/zram0", "swapon -p 100 /dev/zram0"] + ); + } + #[test] fn on_falls_back_when_the_kernel_lacks_zstd() { let t = fake("0\n", "[lzo] lzo-rle lz4\n"); let mut r = Recorder::default(); - let msg = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + let msg = Zram::new(t.path()) + .on(&mut r, &OnOptions::default(), NO_WAIT) + .unwrap(); assert!(msg.contains("zstd is not available"), "{msg}"); assert_eq!( read(&t, "sys/block/zram0/comp_algorithm"), @@ -491,7 +566,9 @@ mod tests { "Filename Type Size Used Priority\n/dev/zram0 partition 4194300 0 100\n", ); let mut r = Recorder::default(); - let msg = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + let msg = Zram::new(t.path()) + .on(&mut r, &OnOptions::default(), NO_WAIT) + .unwrap(); assert!(msg.starts_with("zram swap is already on"), "{msg}"); assert!(r.0.is_empty()); } @@ -504,7 +581,9 @@ mod tests { let mut r = Recorder::default(); // The recorder does not create the device, so this gives up after // asking for the module. - let e = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap_err(); + let e = Zram::new(t.path()) + .on(&mut r, &OnOptions::default(), NO_WAIT) + .unwrap_err(); assert_eq!(r.0, ["modprobe zram"]); assert!(matches!(e, Error::Unsupported(_)), "{e}"); } @@ -517,7 +596,9 @@ mod tests { t.put("proc/sys/vm/dirty_ratio", "20\n"); t.put("proc/sys/vm/dirty_background_ratio", "10\n"); let mut r = Recorder::default(); - Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + Zram::new(t.path()) + .on(&mut r, &OnOptions::default(), NO_WAIT) + .unwrap(); // The recorder cannot add the swap, so the profile sees no zram and // wants its own value; what matters is that tune decided, not 100. assert_eq!(read(&t, "proc/sys/vm/swappiness"), "10"); From 6e323a4441d2b6fc1bf6ba77b99afa9f4731cd8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:34:53 +0000 Subject: [PATCH 11/23] fix(seg): doctor no longer reports shutdown-runlevel services as failed Found by booting the Lite image in QEMU: killprocs, mount-ro and savecache are enabled in OpenRC's `shutdown` runlevel and are, by design, not running while the system is up, yet seg doctor called them "enabled but stopped". Service gets runs_at_boot (enabled in sysinit, boot or default on OpenRC; the same as enabled on the other inits) and doctor looks at that. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- tools/seg/segctl/src/doctor.rs | 11 +++++- tools/seg/segctl/src/svc.rs | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/tools/seg/segctl/src/doctor.rs b/tools/seg/segctl/src/doctor.rs index 6ac97c9..6889b03 100644 --- a/tools/seg/segctl/src/doctor.rs +++ b/tools/seg/segctl/src/doctor.rs @@ -40,7 +40,7 @@ pub enum Outcome { /// Services that start at boot but are not running. pub fn failed_services(list: &[Service]) -> Vec<&Service> { list.iter() - .filter(|s| s.enabled && matches!(s.state, State::Crashed | State::Stopped)) + .filter(|s| s.runs_at_boot && matches!(s.state, State::Crashed | State::Stopped)) .collect() } @@ -395,6 +395,7 @@ mod tests { Service { name: name.into(), enabled, + runs_at_boot: enabled, state, } } @@ -426,6 +427,14 @@ mod tests { assert_eq!(failed, ["b", "c"]); } + #[test] + fn services_of_runlevels_that_boot_does_not_enter_are_not_failures() { + // OpenRC's killprocs, mount-ro and savecache sit in `shutdown`. + let mut late = svc("killprocs", true, State::Stopped); + late.runs_at_boot = false; + assert!(failed_services(&[late]).is_empty()); + } + #[test] fn the_service_fix_names_the_inits_own_command() { let list = [svc("cupsd", true, State::Crashed)]; diff --git a/tools/seg/segctl/src/svc.rs b/tools/seg/segctl/src/svc.rs index 21974bb..d263969 100644 --- a/tools/seg/segctl/src/svc.rs +++ b/tools/seg/segctl/src/svc.rs @@ -37,6 +37,10 @@ impl State { pub struct Service { pub name: String, pub enabled: bool, + /// Enabled in a runlevel the boot goes through. OpenRC also has runlevels + /// that are not entered at boot (`shutdown`, `nonetwork`): a service + /// there is enabled, and not running while the system is up, by design. + pub runs_at_boot: bool, pub state: State, } @@ -224,6 +228,7 @@ impl Svc { /// init can be asked, whether they run. pub fn list(&self) -> Result> { let (available, enabled) = self.available_and_enabled(); + let boot_levels = self.openrc_boot_services(); let states = if self.live() { self.states(&enabled) } else { @@ -233,12 +238,32 @@ impl Svc { .into_iter() .map(|name| Service { enabled: enabled.contains(&name), + runs_at_boot: match self.init { + Init::OpenRc => boot_levels.contains(&name), + _ => enabled.contains(&name), + }, state: states.get(&name).copied().unwrap_or(State::Unknown), name, }) .collect()) } + /// What OpenRC starts on the way up: the `sysinit`, `boot` and `default` + /// runlevels, in that order. + fn openrc_boot_services(&self) -> BTreeSet { + let mut out = BTreeSet::new(); + for level in ["sysinit", "boot", "default"] { + if let Ok(entries) = fs::read_dir(self.p(&format!("/etc/runlevels/{level}"))) { + out.extend( + entries + .flatten() + .filter_map(|e| e.file_name().into_string().ok()), + ); + } + } + out + } + fn available_and_enabled(&self) -> (BTreeSet, BTreeSet) { let names = |dir: &str, want_dirs: bool| -> BTreeSet { let Ok(entries) = fs::read_dir(self.p(dir)) else { @@ -590,6 +615,41 @@ mod tests { assert!(!t.path().join("etc/runlevels").exists()); } + #[test] + fn openrc_shutdown_and_nonetwork_services_do_not_run_at_boot() { + let t = TempDir::new(); + for name in ["agetty", "killprocs", "local", "udev"] { + t.put(&format!("etc/init.d/{name}"), ""); + } + for (level, name) in [ + ("default", "agetty"), + ("shutdown", "killprocs"), + ("nonetwork", "local"), + ("sysinit", "udev"), + ] { + fs::create_dir_all(t.path().join("etc/runlevels").join(level)).unwrap(); + symlink( + format!("/etc/init.d/{name}"), + t.path().join("etc/runlevels").join(level).join(name), + ) + .unwrap(); + } + let list = Svc::new(Init::OpenRc, t.path()).list().unwrap(); + let flags: Vec<_> = list + .iter() + .map(|s| (s.name.as_str(), s.enabled, s.runs_at_boot)) + .collect(); + assert_eq!( + flags, + [ + ("agetty", true, true), + ("killprocs", true, false), + ("local", true, false), + ("udev", true, true), + ] + ); + } + #[test] fn list_combines_installed_and_enabled() { let t = TempDir::new(); @@ -604,11 +664,13 @@ mod tests { Service { name: "a".into(), enabled: false, + runs_at_boot: false, state: State::Unknown }, Service { name: "b".into(), enabled: true, + runs_at_boot: true, state: State::Unknown }, ] From f492caece7fac16536d95222950d9c286424b50a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:37:01 +0000 Subject: [PATCH 12/23] chore(ci): remove the temporary ISO check; docs: after numbers and limits The ISO check built both editions on this branch and booted Lite in QEMU (run 39488187492); its results are in docs/lean-audit.md: the full ISO is 4.7 % smaller and 475 MiB less is installed, Lite is unchanged. The audit claimed the kernel headers were on both editions; Lite never had them, so the table says full only. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- .github/iso-check/inspect.sh | 36 --------- .github/iso-check/qemu_check.py | 130 -------------------------------- .github/workflows/iso-check.yml | 51 ------------- docs/lean-audit.md | 84 +++++++++++++++++++-- 4 files changed, 79 insertions(+), 222 deletions(-) delete mode 100755 .github/iso-check/inspect.sh delete mode 100644 .github/iso-check/qemu_check.py delete mode 100644 .github/workflows/iso-check.yml diff --git a/.github/iso-check/inspect.sh b/.github/iso-check/inspect.sh deleted file mode 100755 index 7c20c1d..0000000 --- a/.github/iso-check/inspect.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# Looks inside a built ISO's system image (without booting it) and prints -# what this branch changed: sizes, what is and is not installed, which -# services are enabled. Usage: inspect.sh ISO -set -uo pipefail -iso=$1 -sudo apt-get install -y -qq squashfs-tools >/dev/null -sudo mkdir -p /mnt/iso -sudo mount -o loop,ro "$iso" /mnt/iso -echo "== ISO: $(stat -c %s "$iso") bytes" -ls -l /mnt/iso/LiveOS /mnt/iso/boot | grep -E 'img|vmlinuz' -list=$(mktemp) -sudo unsquashfs -ll /mnt/iso/LiveOS/rootfs.img > "$list" 2>/dev/null -echo "== listing: $(wc -l < "$list") entries" -has() { grep -qE "squashfs-root/$1( ->.*)?\$" "$list" && echo "present $1" || echo "ABSENT $1"; } -echo "== packages in the image: $(grep -cE 'squashfs-root/var/lib/pacman/local/[^/]+/desc$' "$list")" -echo "== should be there" -for f in usr/bin/seg etc/init.d/seg-zram etc/init.d/seg-tune etc/runlevels/default/seg-zram etc/runlevels/default/seg-tune \ - etc/seg/tune.conf etc/sysctl.d/99-seg.conf etc/cron.d/seg-fstrim usr/lib/seg/fstrim-weekly \ - etc/makepkg.conf.d/10-seglinux.conf usr/lib/seglinux/zram-setup usr/lib/udev/rules.d/60-seglinux-ioschedulers.rules \ - etc/runit/sv/seg-zram/run etc/dinit.d/seg-zram; do has "$f"; done -if [[ $EDITION == full ]]; then - for f in usr/bin/rg usr/bin/fd usr/bin/eza usr/bin/zoxide etc/bash/bashrc.d/seglinux-rust-tools.bashrc usr/bin/xwayland usr/bin/Xwayland; do has "$f"; done -fi -echo "== should be gone" -for f in usr/bin/Xorg usr/bin/spectacle usr/bin/inxi; do has "$f"; done -echo "kernel header folders: $(grep -cE 'squashfs-root/usr/src/[^/]+$' "$list") (0 expected)" -echo "== enabled in the image" -grep -E 'squashfs-root/etc/runlevels/[a-z]+/[^/]+ ->' "$list" | sed -E 's#.*squashfs-root/etc/runlevels/##; s# ->.*##' | sort | tr '\n' ' ' -echo -echo "== packages that need the most room (installed size, MiB)" -sudo unsquashfs -f -d /tmp/rt /mnt/iso/LiveOS/rootfs.img var/lib/pacman/local >/dev/null 2>&1 -for d in /tmp/rt/var/lib/pacman/local/*/; do - n=$(basename "$d"); size=$(awk '/^%SIZE%/{getline; print $1}' "$d/desc"); echo "$((size / 1048576)) $n" -done | sort -rn | head -12 -echo "total installed MiB: $(for d in /tmp/rt/var/lib/pacman/local/*/; do awk '/^%SIZE%/{getline; s+=$1} END{print s+0}' "$d/desc"; done | awk '{t+=$1} END{printf "%d", t / 1048576}')" diff --git a/.github/iso-check/qemu_check.py b/.github/iso-check/qemu_check.py deleted file mode 100644 index 40b5914..0000000 --- a/.github/iso-check/qemu_check.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -"""Boot an ISO headless in QEMU, log in on a text console and run seg. - -Best effort and for the log only: it drives QEMU's monitor (screendump and -sendkey), reads the screen with tesseract and prints what it saw. It always -exits 0; the image checks before it are what fail the job. - -usage: qemu_check.py ISO [boot_seconds] -""" -import os -import socket -import subprocess -import sys -import time - -from PIL import Image - -ISO = sys.argv[1] -BOOT_WAIT = int(sys.argv[2]) if len(sys.argv) > 2 else 240 -MON = "/tmp/qemu-mon.sock" -WORK = "/tmp/qemu-check" -os.makedirs(WORK, exist_ok=True) - -KEYS = {" ": "spc", "-": "minus", ".": "dot", ";": "semicolon", "/": "slash", "\n": "ret", "_": "shift-minus", "=": "equal"} - - -def log(msg): - print(msg, flush=True) - - -class Monitor: - def __init__(self, path): - deadline = time.time() + 30 - while True: - try: - self.sock = socket.socket(socket.AF_UNIX) - self.sock.connect(path) - break - except OSError: - if time.time() > deadline: - raise - time.sleep(0.5) - self.sock.settimeout(5) - self.read() - - def read(self): - data = b"" - try: - while True: - chunk = self.sock.recv(4096) - if not chunk: - break - data += chunk - if data.rstrip().endswith(b"(qemu)"): - break - except socket.timeout: - pass - return data.decode(errors="replace") - - def cmd(self, text): - self.sock.sendall(text.encode() + b"\n") - return self.read() - - def key(self, name): - self.cmd(f"sendkey {name}") - time.sleep(0.12) - - def type(self, text): - for ch in text: - self.key(KEYS.get(ch, ch)) - - def screen_text(self, tag): - ppm = f"{WORK}/{tag}.ppm" - self.cmd(f"screendump {ppm}") - time.sleep(1) - png = f"{WORK}/{tag}.png" - try: - img = Image.open(ppm) - img = img.resize((img.width * 2, img.height * 2)) - img.save(png) - out = subprocess.run(["tesseract", png, "-", "--psm", "6"], capture_output=True, text=True, timeout=60).stdout - except Exception as e: # the screen may not be written yet - return f"(no screen: {e})" - return "\n".join(line for line in out.splitlines() if line.strip()) - - -def main(): - args = ["qemu-system-x86_64", "-m", "3072", "-smp", "2", "-cdrom", ISO, "-boot", "d", - "-display", "none", "-vga", "std", "-monitor", f"unix:{MON},server,nowait", - "-netdev", "user,id=n0", "-device", "virtio-net-pci,netdev=n0"] - if os.access("/dev/kvm", os.R_OK | os.W_OK): - args += ["-enable-kvm", "-cpu", "host"] - log("qemu: using KVM") - else: - args += ["-cpu", "max"] - log("qemu: no KVM, emulating (slow)") - boot_wait = BOOT_WAIT if "-enable-kvm" in args else max(BOOT_WAIT, 420) - qemu = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) - try: - mon = Monitor(MON) - start = time.time() - n = 0 - while time.time() - start < boot_wait: - time.sleep(30) - n += 1 - text = mon.screen_text(f"boot{n}") - log(f"--- screen after {int(time.time() - start)} s:\n" + "\n".join(text.splitlines()[:12])) - # Switch to the second console, where agetty waits for a login. - mon.cmd("sendkey ctrl-alt-f2") - time.sleep(5) - log("--- console 2:\n" + mon.screen_text("tty2")) - mon.type("seglinux\n") - time.sleep(4) - mon.type("seglinux\n") - time.sleep(8) - for i, command in enumerate(["seg info", "seg zram status", "seg tune", "seg svc list --enabled", "seg doctor"]): - mon.type("clear\n") - time.sleep(1) - mon.type(command + "\n") - time.sleep(12) - log(f"--- $ {command}:\n" + mon.screen_text(f"cmd{i}")) - except Exception as e: - log(f"qemu check stopped: {type(e).__name__}: {e}") - finally: - qemu.kill() - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/iso-check.yml b/.github/workflows/iso-check.yml deleted file mode 100644 index d939bad..0000000 --- a/.github/workflows/iso-check.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: ISO check - -# Builds an image on a branch WITHOUT publishing anything, looks inside it -# and boots the Lite one in QEMU. Starts on a push to the work branch whose -# commit message contains "[iso check]". Temporary: removed once the -# seg/rust-lean-overhaul branch is checked. -on: - push: - branches: [seg/rust-lean-overhaul] - -permissions: - contents: read - -jobs: - iso: - name: Build and inspect (${{ matrix.edition }}, openrc, linux-zen) - if: contains(github.event.head_commit.message, '[iso check]') - runs-on: ubuntu-latest - timeout-minutes: 150 - strategy: - fail-fast: false - matrix: - edition: [lite, full] - env: - EDITION: ${{ matrix.edition }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Free disk space - run: | - sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL - sudo docker system prune -af > /dev/null - df -h / - - name: Load the loop module - run: sudo modprobe loop - - name: Build - run: | - edition=() - [[ $EDITION == lite ]] && edition=(--lite) - SEGLINUX_PKGVER="0.1.0.$(git rev-list --count origin/main)" \ - ./iso/build.sh "${edition[@]}" -e docker -i openrc -k linux-zen - - name: Inside the image - run: .github/iso-check/inspect.sh "$(ls iso/out/seglinux-*.iso)" - - name: Boot it in QEMU (Lite) - if: matrix.edition == 'lite' - continue-on-error: true - run: | - sudo apt-get install -y -qq qemu-system-x86 tesseract-ocr python3-pil >/dev/null - [[ -e /dev/kvm ]] && sudo chmod 666 /dev/kvm || true - python3 .github/iso-check/qemu_check.py "$(ls iso/out/seglinux-*.iso)" 150 diff --git a/docs/lean-audit.md b/docs/lean-audit.md index ff406cb..9e0c70e 100644 --- a/docs/lean-audit.md +++ b/docs/lean-audit.md @@ -32,16 +32,65 @@ linux-zen), the last ISO build before this work. | Boot time in QEMU | not measured | not measured | | Rust code | 24,849 lines, 7 crates | | -Boot time and idle RAM could not be measured for this work: the sandbox it -ran in has no route to the Artix mirrors, no QEMU and no loop devices, so an -ISO cannot be built or booted there (see "Verification limits" in the final -section). +Boot time and idle RAM could not be measured when the audit was written: the +sandbox it ran in has no route to the Artix mirrors, no QEMU and no loop +devices. The "After" numbers come from a GitHub Actions build instead. + +## After + +Measured by the temporary "ISO check" workflow (since removed) on 20 October +2026, commit `57a1b1f` of this branch: both editions built with the real +`build-in-container.sh` (OpenRC, linux-zen) and the images opened. The +repositories are rolling, so every package is a day newer than in the +"Before" build; that is part of the differences, mostly the small ones. + +| Metric | Lite before | Lite after | Full before | Full after | +|---|---|---|---|---| +| ISO | 774,014,976 B | 774,531,072 B (+0.07 %) | 3,030,378,496 B | **2,887,837,696 B (-4.7 %, -136 MiB)** | +| `LiveOS/rootfs.img` | 637.2 MiB | 637.6 MiB | 2,681.8 MiB | **2,545.8 MiB (-5.1 %)** | +| initramfs | 49.7 MiB | 49.7 MiB | 156.7 MiB | 156.7 MiB | +| Installed system, uncompressed | 1,790.1 MiB | 1,792.0 MiB | 7,139.8 MiB | **about 6,665 MiB (-6.6 %, -475 MiB)** | +| Package list entries (OpenRC) | 48 | 49 (+`segctl`) | 164 (159 unique) | **152 (152 unique)** | +| Packages in the built image | not recorded | 351 | not recorded | 1,083 | +| Services started in the live session | 1 | 1 | 8 | 7 (no `cupsd`) | +| Services Setup enables on an installed system | 14 | 15 (-`cupsd`, +`seg-zram`, +`seg-tune`) | | | +| Kernel header folders on the image | 0 | 0 | 1 (325 MiB) | 0 | +| Rust code | 24,849 lines | 29,610 (24,907 in `crates/`, 4,703 new in `tools/seg/`) | | | +| `seg` binary | | 567,536 B (stripped, `opt-level = "z"`, LTO, `panic = "abort"`) | | | +| Idle RAM | not measured | 430.7 MiB of 2.9 GiB in use, 2 min after boot, QEMU/KVM, logged in on a text console | 792 MB (15 Oct, other setup) | not measured | +| Boot time | not measured | not measured (see below) | not measured | not measured | + +The Lite edition never had kernel headers (it has its own `common.yaml`), so +removing them only shrinks the full edition; Lite grew by the `segctl` +package (a 0.6 MB binary and a few service files) and the day's package +updates. The full edition's saving is the headers (325 MiB), `spectacle` with +`opencv` (113 MiB), the X server stack and `inxi`. + +What the image check confirmed, per edition: `seg`, the zram and tune +services (OpenRC links, runit and dinit definitions), the weekly `fstrim` +job, the makepkg drop-in, the I/O scheduler rule and, in the full edition, +`rg`, `fd`, `eza`, `zoxide`; `Xorg`, `spectacle` and `inxi` are gone. + +What booting Lite in QEMU (KVM, text console, 150 s of waiting) showed: +`linux-zen` 7.2.11 comes up on OpenRC, the `modules` service loads `ntsync`, +`tcp_bbr`, `sch_fq` and `zram`, `seg-tune` and `seg-zram` start, `seg zram +status` reports an active 1.4 GiB zstd device, and `seg info`, `seg tune`, +`seg svc list --enabled` and `seg doctor` work on the real system. It also +found a bug of the new code: `seg doctor` called `killprocs`, `mount-ro` and +`savecache` (OpenRC `shutdown` runlevel) "enabled but stopped". Fixed in +`6e323a4`; the fix is covered by a unit test and was **not** booted again. +The same boot showed `vm.swappiness` 100 under zram; Setup's 180 is kept +since `cfd6ba8`, also not booted again. + +Boot time is not reported: the harness looks at the screen every 31 s, and +QEMU on a shared CI runner is not a stable clock. The full edition was built +and opened, not booted. ## Bloat table | Item | Size / cost | Why it is bloat | Action | |---|---|---|---| -| `linux-zen-headers` (`linux-headers` in the list) | 325.1 MiB, on both editions (Lite's Setup downloads it too) | Only needed to build `nvidia-open-dkms` on GTX 16 / RTX GPUs. Setup already downloads that driver in a step that needs a network. | **remove** from the image; Setup adds the running kernel's headers to the NVIDIA step | +| `linux-zen-headers` (`linux-headers` in the list) | 325.1 MiB, on the full edition (`common.yaml`; Lite has its own list and never had it) | Only needed to build `nvidia-open-dkms` on GTX 16 / RTX GPUs. Setup already downloads that driver in a step that needs a network. | **remove** from the image; Setup adds the running kernel's headers to the NVIDIA step | | `spectacle` (and `opencv`, which only it needs) | `opencv` 112.8 MiB, plus the libraries only opencv uses | Second screenshot tool: the desktop has its own (Print, Super+Shift+S: grim + slurp). Spectacle would only serve the Plasma fallback session. | **remove** | | `xorg-server`, `xf86-input-*`, `xf86-video-*` | tens of MiB | Legacy X server and its drivers. The sessions are Wayland (labwc, Plasma Wayland); applications use XWayland. The Lite image already ships only `xorg-xwayland`. | **replace** with `xorg-xwayland` only, as Lite does | | `inxi` | ~1 MiB (Perl stays: OpenRC needs it) | Third system-information tool beside fastfetch and KInfoCenter | **remove** | @@ -143,3 +192,28 @@ These are product choices, so they were measured and left alone: `qt6-webengine`. 4. **A Qt 6 media player instead of VLC**: removes Qt 5, about 100 MiB. 5. **Brave from Chaotic-AUR**: the only AUR-origin package of the image. + +## Verification limits + +Run for this work: `cargo fmt --check`, `cargo clippy -- -D warnings` and +`cargo test` for the workspace and for `tools/seg`; `shellcheck` on every +shell script of the repository; the CI jobs of `.github/workflows/ci.yml` +(package lists resolve with `pacman -Sp` in an `artixlinux` container, the +udev rules pass `udevadm verify`, no package is listed twice); `seg` itself +inside an Artix container (`info`, `doctor`, `clean`, `tune`, `zram status`, +`svc` against the real OpenRC and dinit layouts); the fstrim script's stamp +logic; both ISOs built in CI and the Lite one booted in QEMU, as above. + +Not done, and not claimed: + +* No ISO was built or booted in the sandbox the work was done in (no + mirrors, no QEMU, no loop devices): every ISO fact above comes from CI. +* Only the OpenRC images were built. The runit, dinit and s6 definitions + were checked against the files the Artix packages install and by their + unit tests, not by booting those inits. +* The full edition was not booted, so its idle RAM and boot time are not + known, and `cfd6ba8` and `6e323a4` (after the image check) were not + booted. +* The installed-system path (Setup writing the files, `grub-install`) was not + run; its plan is covered by the unit tests. +* No real hardware: nothing about GPUs, cpufreq or NVMe was exercised. From cd1f78c9adea329a8e56d8a9654b022393f832a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:31:02 +0000 Subject: [PATCH 13/23] feat(seg-game): seg game run|check|setup|apply|audio|unsafe-boost, seg tune gaming A lightweight GameMode inside the existing seg tool (libc is the only new dependency; every unsafe block is in game/proc.rs). - seg game run -- CMD (Steam: `seg game run -- %command%`): CPU governor and energy preference to performance and an AMD GPU to its high power level for the length of the game, through a root helper that several games share (state in /run/seg, dead games noticed by pid and start time); nice -4 and best I/O priority for the game; the screen saver held off over a hand-written D-Bus call (org.freedesktop.ScreenSaver.Inhibit); optional pinning to the V-Cache complex of an X3D CPU (found from the L3 cache sizes in /sys); MangoHud and gamescope wrapping; shader cache sizes set. Everything is put back when the game ends, on SIGINT/SIGTERM/SIGHUP (INT is not forwarded: the terminal already did) and when seg itself panics (a hook, since panic=abort skips destructors). - Per-game profiles in ~/.config/seg/games.toml, matched by the names in the command (a Proton command line matches its .exe); a small TOML subset parser that names the line of a mistake. - seg game check: 15 PASS/WARN/FAIL checks (Vulkan, lib32 drivers, GPU driver, ntsync, max_map_count, open files, kernel and a fallback kernel, amd-pstate, governor, may boost, hugepages, split lock, compaction, mitigations), each failure with its exact fix. - seg game setup: the driver packages for the GPUs found (sysfs + lspci); --yes installs them. Only for hardware that is there. - seg game apply (for the seg-game service), audio on|off (low-latency PipeWire, per user), unsafe-boost on|off (mitigations=off, behind a warning and --i-understand, never done by anything else). - seg tune gaming: low swappiness without zram (180 with it), warm caches, short writeback; the governor stays dynamic. - Presets (all off until asked): MangoHud, low-latency PipeWire, the sudoers rule for the gamemode group, seg-game services for OpenRC, runit, dinit and s6. 183 unit tests use fake /sys and /proc trees in tests/fixtures (7950X3D, second-CCD V-Cache, 7800X3D, ordinary CPUs, a VM; AMD, NVIDIA Ada and Pascal, Intel Arc, a hybrid laptop). Release binary 566,576 -> 722,808 bytes. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- tools/seg/Cargo.lock | 7 + tools/seg/dist/99-seg.conf | 2 +- tools/seg/dist/dinit/seg-game | 4 + tools/seg/dist/mangohud-gaming.conf | 33 + tools/seg/dist/openrc/seg-game | 17 + .../99-seg-lowlatency.conf | 11 + .../pipewire.conf.d/99-seg-lowlatency.conf | 9 + tools/seg/dist/runit/seg-game/run | 6 + tools/seg/dist/s6/seg-game/type | 1 + tools/seg/dist/s6/seg-game/up | 1 + tools/seg/dist/sudoers-seg-game | 6 + tools/seg/segctl/Cargo.toml | 1 + tools/seg/segctl/src/game/apply.rs | 127 +++ tools/seg/segctl/src/game/audio.rs | 207 ++++ tools/seg/segctl/src/game/boost.rs | 463 +++++++++ tools/seg/segctl/src/game/check.rs | 947 ++++++++++++++++++ tools/seg/segctl/src/game/dbus.rs | 442 ++++++++ tools/seg/segctl/src/game/hw.rs | 504 ++++++++++ tools/seg/segctl/src/game/mod.rs | 194 ++++ tools/seg/segctl/src/game/proc.rs | 280 ++++++ tools/seg/segctl/src/game/profile.rs | 411 ++++++++ tools/seg/segctl/src/game/run.rs | 607 +++++++++++ tools/seg/segctl/src/game/setup.rs | 451 +++++++++ tools/seg/segctl/src/game/toml.rs | 371 +++++++ tools/seg/segctl/src/game/unsafe_boost.rs | 311 ++++++ tools/seg/segctl/src/main.rs | 23 +- tools/seg/segctl/src/tune.rs | 139 ++- .../gpu-amd/sys/class/drm/card0-DP-1/status | 1 + .../sys/class/drm/card0/device/boot_vga | 1 + .../gpu-amd/sys/class/drm/card0/device/device | 1 + .../device/power_dpm_force_performance_level | 1 + .../gpu-amd/sys/class/drm/card0/device/uevent | 5 + .../gpu-amd/sys/class/drm/card0/device/vendor | 1 + .../sys/class/drm/card0/device/boot_vga | 1 + .../sys/class/drm/card0/device/device | 1 + .../sys/class/drm/card0/device/uevent | 5 + .../sys/class/drm/card0/device/vendor | 1 + .../sys/class/drm/card1/device/boot_vga | 1 + .../sys/class/drm/card1/device/device | 1 + .../sys/class/drm/card1/device/uevent | 5 + .../sys/class/drm/card1/device/vendor | 1 + .../sys/class/drm/card1/device/boot_vga | 1 + .../sys/class/drm/card1/device/device | 1 + .../sys/class/drm/card1/device/uevent | 5 + .../sys/class/drm/card1/device/vendor | 1 + .../fixtures/gpu-none/sys/class/drm/.keep | 0 .../sys/class/drm/card0/device/boot_vga | 1 + .../sys/class/drm/card0/device/device | 1 + .../sys/class/drm/card0/device/uevent | 5 + .../sys/class/drm/card0/device/vendor | 1 + .../sys/class/drm/card0/device/boot_vga | 1 + .../sys/class/drm/card0/device/device | 1 + .../sys/class/drm/card0/device/uevent | 5 + .../sys/class/drm/card0/device/vendor | 1 + .../sys/class/drm/card0/device/boot_vga | 1 + .../sys/class/drm/card0/device/device | 1 + .../sys/class/drm/card0/device/uevent | 5 + .../sys/class/drm/card0/device/vendor | 1 + .../tests/fixtures/intel-12700/proc/cpuinfo | 11 + .../system/cpu/cpu0/cache/index0/level | 1 + .../cpu/cpu0/cache/index0/shared_cpu_list | 1 + .../devices/system/cpu/cpu0/cache/index0/size | 1 + .../devices/system/cpu/cpu0/cache/index0/type | 1 + .../system/cpu/cpu0/cache/index3/level | 1 + .../cpu/cpu0/cache/index3/shared_cpu_list | 1 + .../devices/system/cpu/cpu0/cache/index3/size | 1 + .../devices/system/cpu/cpu0/cache/index3/type | 1 + .../system/cpu/cpu4/cache/index0/level | 1 + .../cpu/cpu4/cache/index0/shared_cpu_list | 1 + .../devices/system/cpu/cpu4/cache/index0/size | 1 + .../devices/system/cpu/cpu4/cache/index0/type | 1 + .../system/cpu/cpu4/cache/index3/level | 1 + .../cpu/cpu4/cache/index3/shared_cpu_list | 1 + .../devices/system/cpu/cpu4/cache/index3/size | 1 + .../devices/system/cpu/cpu4/cache/index3/type | 1 + .../tests/fixtures/ryzen-5950x/proc/cpuinfo | 11 + .../system/cpu/cpu0/cache/index0/level | 1 + .../cpu/cpu0/cache/index0/shared_cpu_list | 1 + .../devices/system/cpu/cpu0/cache/index0/size | 1 + .../devices/system/cpu/cpu0/cache/index0/type | 1 + .../system/cpu/cpu0/cache/index3/level | 1 + .../cpu/cpu0/cache/index3/shared_cpu_list | 1 + .../devices/system/cpu/cpu0/cache/index3/size | 1 + .../devices/system/cpu/cpu0/cache/index3/type | 1 + .../system/cpu/cpu8/cache/index0/level | 1 + .../cpu/cpu8/cache/index0/shared_cpu_list | 1 + .../devices/system/cpu/cpu8/cache/index0/size | 1 + .../devices/system/cpu/cpu8/cache/index0/type | 1 + .../system/cpu/cpu8/cache/index3/level | 1 + .../cpu/cpu8/cache/index3/shared_cpu_list | 1 + .../devices/system/cpu/cpu8/cache/index3/size | 1 + .../devices/system/cpu/cpu8/cache/index3/type | 1 + .../tests/fixtures/ryzen-7800x3d/proc/cpuinfo | 11 + .../system/cpu/cpu0/cache/index0/level | 1 + .../cpu/cpu0/cache/index0/shared_cpu_list | 1 + .../devices/system/cpu/cpu0/cache/index0/size | 1 + .../devices/system/cpu/cpu0/cache/index0/type | 1 + .../system/cpu/cpu0/cache/index3/level | 1 + .../cpu/cpu0/cache/index3/shared_cpu_list | 1 + .../devices/system/cpu/cpu0/cache/index3/size | 1 + .../devices/system/cpu/cpu0/cache/index3/type | 1 + .../system/cpu/cpu8/cache/index0/level | 1 + .../cpu/cpu8/cache/index0/shared_cpu_list | 1 + .../devices/system/cpu/cpu8/cache/index0/size | 1 + .../devices/system/cpu/cpu8/cache/index0/type | 1 + .../system/cpu/cpu8/cache/index3/level | 1 + .../cpu/cpu8/cache/index3/shared_cpu_list | 1 + .../devices/system/cpu/cpu8/cache/index3/size | 1 + .../devices/system/cpu/cpu8/cache/index3/type | 1 + .../tests/fixtures/ryzen-7950x3d/proc/cpuinfo | 11 + .../system/cpu/cpu0/cache/index0/level | 1 + .../cpu/cpu0/cache/index0/shared_cpu_list | 1 + .../devices/system/cpu/cpu0/cache/index0/size | 1 + .../devices/system/cpu/cpu0/cache/index0/type | 1 + .../system/cpu/cpu0/cache/index3/level | 1 + .../cpu/cpu0/cache/index3/shared_cpu_list | 1 + .../devices/system/cpu/cpu0/cache/index3/size | 1 + .../devices/system/cpu/cpu0/cache/index3/type | 1 + .../system/cpu/cpu16/cache/index0/level | 1 + .../cpu/cpu16/cache/index0/shared_cpu_list | 1 + .../system/cpu/cpu16/cache/index0/size | 1 + .../system/cpu/cpu16/cache/index0/type | 1 + .../system/cpu/cpu16/cache/index3/level | 1 + .../cpu/cpu16/cache/index3/shared_cpu_list | 1 + .../system/cpu/cpu16/cache/index3/size | 1 + .../system/cpu/cpu16/cache/index3/type | 1 + .../system/cpu/cpu24/cache/index0/level | 1 + .../cpu/cpu24/cache/index0/shared_cpu_list | 1 + .../system/cpu/cpu24/cache/index0/size | 1 + .../system/cpu/cpu24/cache/index0/type | 1 + .../system/cpu/cpu24/cache/index3/level | 1 + .../cpu/cpu24/cache/index3/shared_cpu_list | 1 + .../system/cpu/cpu24/cache/index3/size | 1 + .../system/cpu/cpu24/cache/index3/type | 1 + .../system/cpu/cpu8/cache/index0/level | 1 + .../cpu/cpu8/cache/index0/shared_cpu_list | 1 + .../devices/system/cpu/cpu8/cache/index0/size | 1 + .../devices/system/cpu/cpu8/cache/index0/type | 1 + .../system/cpu/cpu8/cache/index3/level | 1 + .../cpu/cpu8/cache/index3/shared_cpu_list | 1 + .../devices/system/cpu/cpu8/cache/index3/size | 1 + .../devices/system/cpu/cpu8/cache/index3/type | 1 + .../ryzen-second-ccd-vcache/proc/cpuinfo | 11 + .../system/cpu/cpu0/cache/index0/level | 1 + .../cpu/cpu0/cache/index0/shared_cpu_list | 1 + .../devices/system/cpu/cpu0/cache/index0/size | 1 + .../devices/system/cpu/cpu0/cache/index0/type | 1 + .../system/cpu/cpu0/cache/index3/level | 1 + .../cpu/cpu0/cache/index3/shared_cpu_list | 1 + .../devices/system/cpu/cpu0/cache/index3/size | 1 + .../devices/system/cpu/cpu0/cache/index3/type | 1 + .../system/cpu/cpu8/cache/index0/level | 1 + .../cpu/cpu8/cache/index0/shared_cpu_list | 1 + .../devices/system/cpu/cpu8/cache/index0/size | 1 + .../devices/system/cpu/cpu8/cache/index0/type | 1 + .../system/cpu/cpu8/cache/index3/level | 1 + .../cpu/cpu8/cache/index3/shared_cpu_list | 1 + .../devices/system/cpu/cpu8/cache/index3/size | 1 + .../devices/system/cpu/cpu8/cache/index3/type | 1 + .../seg/segctl/tests/fixtures/vm/proc/cpuinfo | 3 + .../vm/sys/devices/system/cpu/cpu0/online | 1 + 161 files changed, 5771 insertions(+), 17 deletions(-) create mode 100644 tools/seg/dist/dinit/seg-game create mode 100644 tools/seg/dist/mangohud-gaming.conf create mode 100755 tools/seg/dist/openrc/seg-game create mode 100644 tools/seg/dist/pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf create mode 100644 tools/seg/dist/pipewire/pipewire.conf.d/99-seg-lowlatency.conf create mode 100755 tools/seg/dist/runit/seg-game/run create mode 100644 tools/seg/dist/s6/seg-game/type create mode 100644 tools/seg/dist/s6/seg-game/up create mode 100644 tools/seg/dist/sudoers-seg-game create mode 100644 tools/seg/segctl/src/game/apply.rs create mode 100644 tools/seg/segctl/src/game/audio.rs create mode 100644 tools/seg/segctl/src/game/boost.rs create mode 100644 tools/seg/segctl/src/game/check.rs create mode 100644 tools/seg/segctl/src/game/dbus.rs create mode 100644 tools/seg/segctl/src/game/hw.rs create mode 100644 tools/seg/segctl/src/game/mod.rs create mode 100644 tools/seg/segctl/src/game/proc.rs create mode 100644 tools/seg/segctl/src/game/profile.rs create mode 100644 tools/seg/segctl/src/game/run.rs create mode 100644 tools/seg/segctl/src/game/setup.rs create mode 100644 tools/seg/segctl/src/game/toml.rs create mode 100644 tools/seg/segctl/src/game/unsafe_boost.rs create mode 100644 tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0-DP-1/status create mode 100644 tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/boot_vga create mode 100644 tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/device create mode 100644 tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/power_dpm_force_performance_level create mode 100644 tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/uevent create mode 100644 tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/vendor create mode 100644 tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/boot_vga create mode 100644 tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/device create mode 100644 tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/uevent create mode 100644 tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/vendor create mode 100644 tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/boot_vga create mode 100644 tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/device create mode 100644 tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/uevent create mode 100644 tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/vendor create mode 100644 tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/boot_vga create mode 100644 tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/device create mode 100644 tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/uevent create mode 100644 tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/vendor create mode 100644 tools/seg/segctl/tests/fixtures/gpu-none/sys/class/drm/.keep create mode 100644 tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/boot_vga create mode 100644 tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/device create mode 100644 tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/uevent create mode 100644 tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/vendor create mode 100644 tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/boot_vga create mode 100644 tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/device create mode 100644 tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/uevent create mode 100644 tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/vendor create mode 100644 tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/boot_vga create mode 100644 tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/device create mode 100644 tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/uevent create mode 100644 tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/vendor create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/proc/cpuinfo create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/level create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/size create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/type create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/level create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/size create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/type create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/level create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/size create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/type create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/level create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/size create mode 100644 tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/proc/cpuinfo create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/proc/cpuinfo create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/proc/cpuinfo create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/proc/cpuinfo create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/type create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/level create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/size create mode 100644 tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/type create mode 100644 tools/seg/segctl/tests/fixtures/vm/proc/cpuinfo create mode 100644 tools/seg/segctl/tests/fixtures/vm/sys/devices/system/cpu/cpu0/online diff --git a/tools/seg/Cargo.lock b/tools/seg/Cargo.lock index 2bb4d03..5a4b2a8 100644 --- a/tools/seg/Cargo.lock +++ b/tools/seg/Cargo.lock @@ -8,9 +8,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413" +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + [[package]] name = "segctl" version = "0.1.0" dependencies = [ "lexopt", + "libc", ] diff --git a/tools/seg/dist/99-seg.conf b/tools/seg/dist/99-seg.conf index 93d20fc..a26d03d 100644 --- a/tools/seg/dist/99-seg.conf +++ b/tools/seg/dist/99-seg.conf @@ -1,4 +1,4 @@ -# Written by `seg tune balanced`; change it with `sudo seg tune `. +# Written by `seg tune balanced`; change it with `sudo seg tune `. vm.swappiness = 60 vm.vfs_cache_pressure = 100 vm.dirty_ratio = 20 diff --git a/tools/seg/dist/dinit/seg-game b/tools/seg/dist/dinit/seg-game new file mode 100644 index 0000000..4b2b8c7 --- /dev/null +++ b/tools/seg/dist/dinit/seg-game @@ -0,0 +1,4 @@ +# The gaming sysctls and device permissions (/dev/ntsync) at boot. +type = scripted +command = /usr/bin/seg game apply +restart = false diff --git a/tools/seg/dist/mangohud-gaming.conf b/tools/seg/dist/mangohud-gaming.conf new file mode 100644 index 0000000..bcd2d11 --- /dev/null +++ b/tools/seg/dist/mangohud-gaming.conf @@ -0,0 +1,33 @@ +# MangoHud preset for games, shipped by SegLinux. It is OFF by default: a game +# gets it only with `mangohud = true` and `mangohud_config = "preset"` in its +# ~/.config/seg/games.toml profile, or with +# MANGOHUD_CONFIGFILE=/usr/share/seg/mangohud-gaming.conf mangohud %command% +# Your own ~/.config/MangoHud/MangoHud.conf is never touched. + +legacy_layout=false +position=top-left +font_size=20 +background_alpha=0.35 +round_corners=6 + +# What to show: frame rate and frame time (with its graph), CPU and GPU load +# and temperature, memory. +fps +frametime +frame_timing +cpu_stats +cpu_temp +gpu_stats +gpu_temp +vram +ram + +# Logging for benchmarks (docs/gaming.md): Shift+F2 starts and stops a log. +output_folder=/tmp +log_duration=60 +autostart_log=0 +toggle_logging=Shift_L+F2 +log_interval=100 + +# Hide or show the overlay while playing. +toggle_hud=Shift_R+F12 diff --git a/tools/seg/dist/openrc/seg-game b/tools/seg/dist/openrc/seg-game new file mode 100755 index 0000000..6a51b8e --- /dev/null +++ b/tools/seg/dist/openrc/seg-game @@ -0,0 +1,17 @@ +#!/usr/bin/openrc-run +# shellcheck shell=sh disable=SC2034 +# The gaming sysctls and device permissions (/dev/ntsync) at boot, on any +# init: OpenRC's sysctl service does the sysctls too, this makes sure. + +description="Apply SegLinux's gaming sysctls and device permissions" + +depend() { + after sysctl udev + before seg-tune +} + +start() { + ebegin "Applying the gaming settings" + /usr/bin/seg game apply >/dev/null + eend $? +} diff --git a/tools/seg/dist/pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf b/tools/seg/dist/pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf new file mode 100644 index 0000000..13fca96 --- /dev/null +++ b/tools/seg/dist/pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf @@ -0,0 +1,11 @@ +# The same for programs that talk to PipeWire as PulseAudio (Wine, Proton, +# most games): see pipewire.conf.d/99-seg-lowlatency.conf. +pulse.properties = { + pulse.min.req = 256/48000 + pulse.default.req = 256/48000 + pulse.min.quantum = 256/48000 +} + +stream.properties = { + node.latency = 256/48000 +} diff --git a/tools/seg/dist/pipewire/pipewire.conf.d/99-seg-lowlatency.conf b/tools/seg/dist/pipewire/pipewire.conf.d/99-seg-lowlatency.conf new file mode 100644 index 0000000..1f6f5cd --- /dev/null +++ b/tools/seg/dist/pipewire/pipewire.conf.d/99-seg-lowlatency.conf @@ -0,0 +1,9 @@ +# Lower audio latency for games (seg game audio on): a 256-sample buffer at +# 48 kHz is about 5 ms. If sound crackles, `seg game audio off`. +context.properties = { + default.clock.rate = 48000 + default.clock.allowed-rates = [ 48000 ] + default.clock.quantum = 256 + default.clock.min-quantum = 64 + default.clock.max-quantum = 512 +} diff --git a/tools/seg/dist/runit/seg-game/run b/tools/seg/dist/runit/seg-game/run new file mode 100755 index 0000000..4671aaa --- /dev/null +++ b/tools/seg/dist/runit/seg-game/run @@ -0,0 +1,6 @@ +#!/bin/sh +# Applies the gaming sysctls and device permissions once, then stays "up" so +# runit does not start it again. +exec 2>&1 +/usr/bin/seg game apply +exec sleep infinity diff --git a/tools/seg/dist/s6/seg-game/type b/tools/seg/dist/s6/seg-game/type new file mode 100644 index 0000000..bdd22a1 --- /dev/null +++ b/tools/seg/dist/s6/seg-game/type @@ -0,0 +1 @@ +oneshot diff --git a/tools/seg/dist/s6/seg-game/up b/tools/seg/dist/s6/seg-game/up new file mode 100644 index 0000000..24aaef3 --- /dev/null +++ b/tools/seg/dist/s6/seg-game/up @@ -0,0 +1 @@ +/usr/bin/seg game apply diff --git a/tools/seg/dist/sudoers-seg-game b/tools/seg/dist/sudoers-seg-game new file mode 100644 index 0000000..d66b093 --- /dev/null +++ b/tools/seg/dist/sudoers-seg-game @@ -0,0 +1,6 @@ +# seg game run raises the CPU governor and an AMD GPU's power level while a +# game runs, and puts them back. These two commands, with these arguments and +# nothing else, are all the gamemode group may run as root; the program checks +# the process id and the governor, and touches only the cpufreq and amdgpu +# power files. +%gamemode ALL=(root) NOPASSWD: /usr/bin/seg game boost on [0-9]*, /usr/bin/seg game boost off [0-9]* diff --git a/tools/seg/segctl/Cargo.toml b/tools/seg/segctl/Cargo.toml index 00b608c..8c833b3 100644 --- a/tools/seg/segctl/Cargo.toml +++ b/tools/seg/segctl/Cargo.toml @@ -15,3 +15,4 @@ path = "src/main.rs" [dependencies] lexopt = "0.3" +libc = "0.2" diff --git a/tools/seg/segctl/src/game/apply.rs b/tools/seg/segctl/src/game/apply.rs new file mode 100644 index 0000000..37f2327 --- /dev/null +++ b/tools/seg/segctl/src/game/apply.rs @@ -0,0 +1,127 @@ +//! `seg game apply`, run at boot by the `seg-game` service of every init: +//! the gaming sysctls and the permissions of /dev/ntsync. OpenRC and udev do +//! both too; runit, dinit and s6 images may not, and applying twice is +//! harmless. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use crate::error::{Context, Result}; +use crate::sys; +use crate::tune; + +pub const HELP: &str = "\ +Usage: seg game apply + +Applies /etc/sysctl.d/80-seglinux-gaming.conf now (vm.max_map_count, +kernel.split_lock_mitigate, vm.compaction_proactiveness, ...) and makes +/dev/ntsync usable by every user, so Wine and Proton can use NTSYNC. Needs +root. The seg-game service runs it at boot. +"; + +pub const SYSCTL_FILE: &str = "etc/sysctl.d/80-seglinux-gaming.conf"; + +/// Apply the sysctls of the gaming file below `root`; then fix /dev/ntsync. +pub fn apply(root: &Path) -> Result> { + let mut lines = Vec::new(); + let path = root.join(SYSCTL_FILE); + match fs::read_to_string(&path) { + Ok(text) => { + let report = tune::Fs::new(root).apply_sysctl_text(&text)?; + lines.extend(report.lines); + lines.extend(report.warnings.into_iter().map(|w| format!("note: {w}"))); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + lines.push(format!( + "note: {} does not exist; no sysctls to apply", + path.display() + )); + } + Err(e) => { + return Err(crate::error::Error::io( + format!("reading {}", path.display()), + e, + )); + } + } + let ntsync = root.join("dev/ntsync"); + if let Ok(meta) = fs::metadata(&ntsync) { + let mode = meta.permissions().mode() & 0o777; + if mode & 0o006 != 0o006 { + fs::set_permissions(&ntsync, fs::Permissions::from_mode(0o666)) + .context(format!("opening up {}", ntsync.display()))?; + lines.push(format!("/dev/ntsync {mode:o} -> 666")); + } + } + Ok(lines) +} + +pub fn run() -> Result { + sys::require_root("`seg game apply`")?; + let lines = apply(Path::new("/"))?; + if lines.is_empty() { + outln!("seg game apply: already applied"); + } + for l in lines { + outln!(" {l}"); + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + #[test] + fn the_gaming_file_is_applied_and_ntsync_opened() { + let t = TempDir::new(); + t.put(SYSCTL_FILE, "# gaming\nvm.max_map_count = 2147483642\nkernel.split_lock_mitigate = 0\nvm.compaction_proactiveness = 0\n\nvm.unknown_key = 1\n"); + t.put("proc/sys/vm/max_map_count", "65530\n"); + t.put("proc/sys/kernel/split_lock_mitigate", "1\n"); + t.put("proc/sys/vm/compaction_proactiveness", "0\n"); + let ntsync = t.put("dev/ntsync", ""); + fs::set_permissions(&ntsync, fs::Permissions::from_mode(0o600)).unwrap(); + let lines = apply(t.path()).unwrap(); + let read = |rel: &str| { + fs::read_to_string(t.path().join(rel)) + .unwrap() + .trim() + .to_string() + }; + assert_eq!(read("proc/sys/vm/max_map_count"), "2147483642"); + assert_eq!(read("proc/sys/kernel/split_lock_mitigate"), "0"); + assert!( + lines + .iter() + .any(|l| l.contains("vm.max_map_count") && l.contains("65530 -> 2147483642")), + "{lines:?}" + ); + assert!( + lines + .iter() + .any(|l| l.contains("vm.unknown_key") && l.starts_with("note:")), + "{lines:?}" + ); + assert!( + lines.iter().any(|l| l == "/dev/ntsync 600 -> 666"), + "{lines:?}" + ); + assert_eq!( + fs::metadata(&ntsync).unwrap().permissions().mode() & 0o777, + 0o666 + ); + // Again: nothing to do (the unknown key is still a note). + let again = apply(t.path()).unwrap(); + assert!(again.iter().all(|l| l.starts_with("note:")), "{again:?}"); + } + + #[test] + fn no_file_and_no_device_is_not_an_error() { + let t = TempDir::new(); + let lines = apply(t.path()).unwrap(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("no sysctls to apply"), "{lines:?}"); + } +} diff --git a/tools/seg/segctl/src/game/audio.rs b/tools/seg/segctl/src/game/audio.rs new file mode 100644 index 0000000..237e59f --- /dev/null +++ b/tools/seg/segctl/src/game/audio.rs @@ -0,0 +1,207 @@ +//! `seg game audio on|off|status`: PipeWire's low-latency settings for +//! games. The two drop-in files the segctl package ships in +//! /usr/share/seg/pipewire are copied into the user's own PipeWire +//! configuration (no root), and removed again by `off`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::error::{Context, Error, Result}; + +pub const HELP: &str = "\ +Usage: seg game audio on|off|status + +on lower PipeWire's buffer (256 samples at 48 kHz, about 5 ms) for you +off back to PipeWire's defaults +status say which it is + +Lower latency means less delay between what happens in a game and what you +hear, at the price of more CPU work for the sound server and, on a weak or busy +computer, crackling. If sound crackles, run `seg game audio off`. It applies +the next time PipeWire starts: log out and in, or restart pipewire, +wireplumber and pipewire-pulse. +"; + +/// (file in the package, folder below ~/.config/pipewire, file name there) +const FILES: [(&str, &str); 2] = [ + ("pipewire.conf.d/99-seg-lowlatency.conf", "pipewire.conf.d"), + ( + "pipewire-pulse.conf.d/99-seg-lowlatency.conf", + "pipewire-pulse.conf.d", + ), +]; + +pub const SHARE: &str = "/usr/share/seg/pipewire"; + +pub enum Action { + On, + Off, + Status, +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut word: Option = None; + while let Some(arg) = parser.next()? { + match arg { + Short('h') | Long("help") => { + out!("{HELP}"); + std::process::exit(0); + } + Value(v) if word.is_none() => word = Some(v.string()?), + other => return Err(other.unexpected().into()), + } + } + match word.as_deref() { + Some("on") => Ok(Action::On), + Some("off") => Ok(Action::Off), + None | Some("status") => Ok(Action::Status), + Some(other) => Err(Error::Usage(format!( + "unknown audio command `{other}` (on, off or status)" + ))), + } +} + +fn targets(config: &Path) -> Vec<(PathBuf, PathBuf)> { + FILES + .iter() + .map(|(src, dir)| { + ( + PathBuf::from(src), + config + .join("pipewire") + .join(dir) + .join("99-seg-lowlatency.conf"), + ) + }) + .collect() +} + +pub fn is_on(config: &Path) -> bool { + targets(config).iter().all(|(_, t)| t.exists()) +} + +pub fn enable(share: &Path, config: &Path) -> Result<()> { + for (src, target) in targets(config) { + let from = share.join(src); + let text = fs::read_to_string(&from).context(format!("reading {}", from.display()))?; + if let Some(dir) = target.parent() { + fs::create_dir_all(dir).context(format!("creating {}", dir.display()))?; + } + fs::write(&target, text).context(format!("writing {}", target.display()))?; + } + Ok(()) +} + +pub fn disable(config: &Path) -> Result { + let mut removed = false; + for (_, target) in targets(config) { + match fs::remove_file(&target) { + Ok(()) => removed = true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(Error::io(format!("removing {}", target.display()), e)), + } + } + Ok(removed) +} + +pub fn run(action: Action) -> Result { + let config = match std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) { + Some(dir) => PathBuf::from(dir), + None => crate::sys::invoking_home() + .ok_or_else(|| Error::other("cannot tell where your configuration is (no HOME)"))? + .join(".config"), + }; + match action { + Action::Status => outln!( + "low-latency audio: {}", + if is_on(&config) { "on" } else { "off" } + ), + Action::On => { + enable(Path::new(SHARE), &config)?; + outln!( + "low-latency audio is on. Log out and in (or restart pipewire, wireplumber and pipewire-pulse). If sound crackles: seg game audio off" + ); + } + Action::Off => { + outln!( + "{}", + if disable(&config)? { + "low-latency audio is off. Log out and in (or restart pipewire) to go back to the defaults." + } else { + "low-latency audio was not on." + } + ); + } + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + fn share() -> TempDir { + let t = TempDir::new(); + t.put( + "pipewire.conf.d/99-seg-lowlatency.conf", + "context.properties = {}\n", + ); + t.put( + "pipewire-pulse.conf.d/99-seg-lowlatency.conf", + "pulse.properties = {}\n", + ); + t + } + + #[test] + fn on_copies_both_files_and_off_removes_them() { + let (s, c) = (share(), TempDir::new()); + assert!(!is_on(c.path())); + enable(s.path(), c.path()).unwrap(); + assert!(is_on(c.path())); + assert_eq!( + fs::read_to_string( + c.path() + .join("pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf") + ) + .unwrap(), + "pulse.properties = {}\n" + ); + // Twice is fine. + enable(s.path(), c.path()).unwrap(); + assert!(disable(c.path()).unwrap()); + assert!(!is_on(c.path())); + assert!(!disable(c.path()).unwrap(), "nothing left to remove"); + } + + #[test] + fn a_missing_package_file_is_an_error() { + let c = TempDir::new(); + let e = enable(Path::new("/nowhere"), c.path()) + .unwrap_err() + .to_string(); + assert!(e.contains("reading /nowhere/pipewire.conf.d"), "{e}"); + } + + #[test] + fn other_files_in_the_config_folders_are_left_alone() { + let (s, c) = (share(), TempDir::new()); + let mine = c.put("pipewire/pipewire.conf.d/50-mine.conf", "x\n"); + enable(s.path(), c.path()).unwrap(); + disable(c.path()).unwrap(); + assert!(mine.exists()); + } + + #[test] + fn the_shipped_files_say_what_the_help_says() { + let pw = include_str!("../../../dist/pipewire/pipewire.conf.d/99-seg-lowlatency.conf"); + assert!(pw.contains("default.clock.quantum")); + assert!(pw.contains("256")); + let pulse = + include_str!("../../../dist/pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf"); + assert!(pulse.contains("pulse.min.req")); + assert!(pulse.contains("256/48000")); + } +} diff --git a/tools/seg/segctl/src/game/boost.rs b/tools/seg/segctl/src/game/boost.rs new file mode 100644 index 0000000..3ad81f4 --- /dev/null +++ b/tools/seg/segctl/src/game/boost.rs @@ -0,0 +1,463 @@ +//! The part of `seg game run` that needs root: while at least one game runs, +//! the CPU governor and energy preference are `performance` and an AMD GPU is +//! held at its high power level; when the last game ends, every value goes +//! back to what it was. +//! +//! `seg game run` reaches this through `sudo -n seg game boost on|off PID` +//! (the sudoers file of the segctl package allows exactly that to the +//! `gamemode` group) or calls it directly when it runs as root. Several games +//! share one state file, /run/seg/game-boost, under a lock; a game that died +//! without saying so (SIGKILL, power loss of the session) is noticed by its +//! process id and start time and does not keep the boost alive. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +use super::hw::{self, GpuVendor}; +use super::proc; +use crate::error::{Context, Error, Result}; +use crate::sys; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Owner { + pub pid: u32, + /// Field 22 of /proc/PID/stat: tells a reused pid from the original. + pub start: u64, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct State { + pub owners: Vec, + /// (file, value it had before the boost) + pub saved: Vec<(String, String)>, +} + +const HEADER: &str = "seg-game-boost 1"; + +pub fn serialize(state: &State) -> String { + let mut text = format!("{HEADER}\n"); + for o in &state.owners { + text.push_str(&format!("owner {} {}\n", o.pid, o.start)); + } + for (path, value) in &state.saved { + text.push_str(&format!("saved\t{path}\t{value}\n")); + } + text +} + +/// Parse a state file; a file that is not ours gives an empty state. +pub fn parse_state(text: &str) -> State { + let mut lines = text.lines(); + if lines.next() != Some(HEADER) { + return State::default(); + } + let mut state = State::default(); + for line in lines { + if let Some(rest) = line.strip_prefix("owner ") { + let mut f = rest.split_whitespace(); + if let (Some(pid), Some(start)) = (f.next(), f.next()) + && let (Ok(pid), Ok(start)) = (pid.parse(), start.parse()) + { + state.owners.push(Owner { pid, start }); + } + } else if let Some(rest) = line.strip_prefix("saved\t") + && let Some((path, value)) = rest.split_once('\t') + { + state.saved.push((path.to_string(), value.to_string())); + } + } + state +} + +/// The start time (field 22) of `/proc/PID/stat`. The command name is in +/// parentheses and may contain spaces and parentheses, so count from the +/// last `)`. +pub fn parse_start_time(stat: &str) -> Option { + let after = stat.get(stat.rfind(')')? + 1..)?; + // Fields after the name start at field 3 (state); starttime is 22. + after.split_whitespace().nth(22 - 3)?.parse().ok() +} + +#[derive(Debug, Default)] +pub struct Report { + pub lines: Vec, + pub warnings: Vec, +} + +pub struct Boost { + root: PathBuf, +} + +impl Boost { + pub fn new(root: impl Into) -> Self { + Boost { root: root.into() } + } + + fn state_dir(&self) -> PathBuf { + self.root.join("run/seg") + } + + fn start_time(&self, pid: u32) -> Option { + parse_start_time(&fs::read_to_string(self.root.join(format!("proc/{pid}/stat"))).ok()?) + } + + fn alive(&self, o: &Owner) -> bool { + self.start_time(o.pid) == Some(o.start) + } + + fn load(&self) -> State { + fs::read_to_string(self.state_dir().join("game-boost")) + .map(|t| parse_state(&t)) + .unwrap_or_default() + } + + fn store(&self, state: &State) -> Result<()> { + let path = self.state_dir().join("game-boost"); + if state.owners.is_empty() && state.saved.is_empty() { + let _ = fs::remove_file(&path); + return Ok(()); + } + sys::write_atomic(&path, &serialize(state))?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .context(format!("securing {}", path.display())) + } + + /// Run `f` holding the lock of the state directory. + fn locked(&self, f: impl FnOnce() -> Result) -> Result { + let dir = self.state_dir(); + fs::create_dir_all(&dir).context(format!("creating {}", dir.display()))?; + let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)); + let lock = dir.join("game.lock"); + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock) + .context(format!("opening {}", lock.display()))?; + proc::lock_exclusive(&file).context(format!("locking {}", lock.display()))?; + f() + } + + /// The files to change, with the value that makes the computer fast. + pub fn knobs(&self, governor: &str) -> Vec<(PathBuf, String)> { + let mut knobs = Vec::new(); + for dir in hw::cpufreq_dirs(&self.root) { + let available = + sys::read_trimmed(dir.join("scaling_available_governors")).unwrap_or_default(); + if available.split_whitespace().any(|g| g == governor) { + knobs.push((dir.join("scaling_governor"), governor.to_string())); + } + if governor == "performance" { + let epp = dir.join("energy_performance_preference"); + let preferences = + sys::read_trimmed(dir.join("energy_performance_available_preferences")); + let allowed = preferences + .as_deref() + .is_none_or(|p| p.split_whitespace().any(|x| x == "performance")); + if epp.exists() && allowed { + knobs.push((epp, "performance".to_string())); + } + } + } + if governor == "performance" { + for gpu in hw::gpus(&self.root) { + if gpu.vendor == GpuVendor::Amd && gpu.driver.as_deref() == Some("amdgpu") { + let level = self.root.join(format!( + "sys/class/drm/{}/device/power_dpm_force_performance_level", + gpu.card + )); + if level.exists() { + knobs.push((level, "high".to_string())); + } + } + } + } + knobs + } + + /// Start the boost for the game process `pid`, or join the one that is + /// on. The first game decides the governor. + pub fn on(&self, pid: u32, governor: &str) -> Result { + let start = self + .start_time(pid) + .ok_or_else(|| Error::other(format!("no process {pid}")))?; + self.locked(|| { + let mut report = Report::default(); + let mut state = self.load(); + state.owners.retain(|o| self.alive(o)); + state.owners.retain(|o| o.pid != pid); + if state.owners.is_empty() { + // Nobody boosted: remember what is there now, then change it. + // (A leftover `saved` of games that all died is the truth, so + // it is put back first.) + self.restore(&mut state, &mut report); + for (path, value) in self.knobs(governor) { + let Some(before) = sys::read_trimmed(&path) else { + continue; + }; + if before == value { + continue; + } + match fs::write(&path, &value) { + Ok(()) => { + state.saved.push((path.display().to_string(), before)); + } + Err(e) => report.warnings.push(format!("{}: {e}", path.display())), + } + } + report.lines.push(format!( + "boost on: {} settings changed ({governor})", + state.saved.len() + )); + } else { + report + .lines + .push("boost already on for another game".into()); + } + state.owners.push(Owner { pid, start }); + self.store(&state)?; + Ok(report) + }) + } + + fn restore(&self, state: &mut State, report: &mut Report) { + for (path, value) in state.saved.drain(..) { + if let Err(e) = fs::write(&path, &value) { + report.warnings.push(format!("{path}: {e}")); + } + } + } + + /// The game `pid` has ended: when it was the last, put everything back. + pub fn off(&self, pid: u32) -> Result { + self.locked(|| { + let mut report = Report::default(); + let mut state = self.load(); + state.owners.retain(|o| o.pid != pid && self.alive(o)); + if state.owners.is_empty() { + let count = state.saved.len(); + self.restore(&mut state, &mut report); + report + .lines + .push(format!("boost off: {count} settings restored")); + } else { + report.lines.push("boost stays on for another game".into()); + } + self.store(&state)?; + Ok(report) + }) + } + + pub fn status(&self) -> String { + let state = self.load(); + let live = state.owners.iter().filter(|o| self.alive(o)).count(); + format!( + "game boost: {} ({live} game{} running, {} settings saved)\n", + if live > 0 { "on" } else { "off" }, + if live == 1 { "" } else { "s" }, + state.saved.len() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + fn proc_entry(t: &TempDir, pid: u32, start: u64) { + t.put( + &format!("proc/{pid}/stat"), + &format!("{pid} (my game (x)) S 1 {pid} {pid} 0 -1 4194560 100 0 0 0 1 1 0 0 20 0 1 0 {start} 1000 100 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 17 3 0 0 0 0 0\n"), + ); + } + + /// Two CPUs with cpufreq, an AMD GPU, and two games' worth of processes. + fn machine() -> TempDir { + let t = TempDir::new(); + for n in [0, 1] { + let d = format!("sys/devices/system/cpu/cpu{n}/cpufreq"); + t.put( + &format!("{d}/scaling_available_governors"), + "performance schedutil powersave\n", + ); + t.put(&format!("{d}/scaling_governor"), "schedutil\n"); + t.put( + &format!("{d}/energy_performance_preference"), + "balance_performance\n", + ); + t.put( + &format!("{d}/energy_performance_available_preferences"), + "default performance balance_performance balance_power power\n", + ); + } + let g = "sys/class/drm/card0/device"; + t.put(&format!("{g}/vendor"), "0x1002\n"); + t.put(&format!("{g}/device"), "0x744c\n"); + t.put( + &format!("{g}/uevent"), + "DRIVER=amdgpu\nPCI_SLOT_NAME=0000:03:00.0\n", + ); + t.put(&format!("{g}/power_dpm_force_performance_level"), "auto\n"); + proc_entry(&t, 100, 5000); + proc_entry(&t, 200, 6000); + t + } + + fn read(t: &TempDir, rel: &str) -> String { + fs::read_to_string(t.path().join(rel)) + .unwrap() + .trim() + .to_string() + } + + const GOV: &str = "sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"; + const EPP: &str = "sys/devices/system/cpu/cpu1/cpufreq/energy_performance_preference"; + const GPU: &str = "sys/class/drm/card0/device/power_dpm_force_performance_level"; + + #[test] + fn state_files_round_trip() { + let s = State { + owners: vec![ + Owner { pid: 1, start: 10 }, + Owner { + pid: 22, + start: 333, + }, + ], + saved: vec![ + ("/sys/a b/gov".into(), "schedutil".into()), + ("/sys/x".into(), "auto".into()), + ], + }; + assert_eq!(parse_state(&serialize(&s)), s); + assert_eq!(parse_state("something else\n"), State::default()); + assert_eq!(parse_state(""), State::default()); + // Damaged lines are skipped, the rest is kept. + let s = parse_state("seg-game-boost 1\nowner x y\nowner 5 6\nsaved\tnotab\n"); + assert_eq!(s.owners, vec![Owner { pid: 5, start: 6 }]); + assert!(s.saved.is_empty()); + } + + #[test] + fn the_start_time_survives_awkward_names() { + let stat = "4242 (Web Content (1)) S 1 4242 4242 0 -1 4194560 1 0 0 0 1 1 0 0 20 0 1 0 987654 1 1 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 17 3 0 0 0 0 0"; + assert_eq!(parse_start_time(stat), Some(987654)); + assert_eq!(parse_start_time("garbage"), None); + assert_eq!(parse_start_time("1 (x) S 1"), None); + } + + #[test] + fn knobs_follow_what_the_machine_offers() { + let t = machine(); + let b = Boost::new(t.path()); + let perf = b.knobs("performance"); + // Two governors, two EPPs, the GPU. + assert_eq!(perf.len(), 5); + assert!(perf.iter().any(|(p, v)| { + p.ends_with("card0/device/power_dpm_force_performance_level") && v == "high" + })); + // Another governor changes the governor only. + assert_eq!(b.knobs("powersave").len(), 2); + // A governor the CPU lacks changes nothing. + assert!(b.knobs("ondemand").is_empty()); + } + + #[test] + fn a_game_boosts_and_the_end_of_the_game_restores() { + let t = machine(); + let b = Boost::new(t.path()); + let r = b.on(100, "performance").unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(read(&t, GOV), "performance"); + assert_eq!(read(&t, EPP), "performance"); + assert_eq!(read(&t, GPU), "high"); + assert!( + b.status().starts_with("game boost: on (1 game running"), + "{}", + b.status() + ); + + let r = b.off(100).unwrap(); + assert!(r.lines[0].contains("5 settings restored"), "{:?}", r.lines); + assert_eq!(read(&t, GOV), "schedutil"); + assert_eq!(read(&t, EPP), "balance_performance"); + assert_eq!(read(&t, GPU), "auto"); + assert!(!t.path().join("run/seg/game-boost").exists()); + assert!(b.status().starts_with("game boost: off")); + } + + #[test] + fn two_games_share_one_boost() { + let t = machine(); + let b = Boost::new(t.path()); + b.on(100, "performance").unwrap(); + // The second game must not save "performance" as the original. + let r = b.on(200, "performance").unwrap(); + assert!(r.lines[0].contains("already on"), "{:?}", r.lines); + b.off(100).unwrap(); + assert_eq!(read(&t, GOV), "performance", "the other game still runs"); + b.off(200).unwrap(); + assert_eq!(read(&t, GOV), "schedutil"); + } + + #[test] + fn a_game_that_died_does_not_keep_the_boost_on() { + let t = machine(); + let b = Boost::new(t.path()); + b.on(100, "performance").unwrap(); + // Game 100 was killed (its process is gone); game 200 starts and ends. + fs::remove_dir_all(t.path().join("proc/100")).unwrap(); + let r = b.on(200, "performance").unwrap(); + assert!(!r.lines[0].contains("already on"), "{:?}", r.lines); + b.off(200).unwrap(); + // Back to the original values, not to "performance". + assert_eq!(read(&t, GOV), "schedutil"); + assert_eq!(read(&t, GPU), "auto"); + } + + #[test] + fn a_reused_process_id_is_not_the_old_game() { + let t = machine(); + let b = Boost::new(t.path()); + b.on(100, "performance").unwrap(); + // pid 100 now belongs to a different process (other start time). + proc_entry(&t, 100, 9999); + b.off(200).unwrap(); + assert_eq!(read(&t, GOV), "schedutil"); + } + + #[test] + fn off_without_on_changes_nothing() { + let t = machine(); + let b = Boost::new(t.path()); + let r = b.off(100).unwrap(); + assert!(r.lines[0].contains("0 settings restored"), "{:?}", r.lines); + assert_eq!(read(&t, GOV), "schedutil"); + } + + #[test] + fn a_missing_process_is_refused() { + let t = machine(); + let e = Boost::new(t.path()).on(777, "performance").unwrap_err(); + assert!(e.to_string().contains("no process 777"), "{e}"); + assert_eq!(read(&t, GOV), "schedutil"); + } + + #[test] + fn the_state_directory_is_private() { + let t = machine(); + let b = Boost::new(t.path()); + b.on(100, "performance").unwrap(); + let mode = |rel: &str| { + fs::metadata(t.path().join(rel)) + .unwrap() + .permissions() + .mode() + & 0o777 + }; + assert_eq!(mode("run/seg"), 0o700); + assert_eq!(mode("run/seg/game-boost"), 0o600); + } +} diff --git a/tools/seg/segctl/src/game/check.rs b/tools/seg/segctl/src/game/check.rs new file mode 100644 index 0000000..af252fc --- /dev/null +++ b/tools/seg/segctl/src/game/check.rs @@ -0,0 +1,947 @@ +//! `seg game check`: is this computer ready for games? One line per +//! question, PASS or FAIL (or WARN when it works but is not as good as it +//! could be), and the exact command that fixes what is not right. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use super::hw::{self, Gpu, GpuVendor}; +use crate::error::Result; +use crate::sys; + +pub const HELP: &str = "\ +Usage: seg game check + +Checks Vulkan, the 32-bit graphics drivers, the GPU driver, NTSYNC, +vm.max_map_count, the open files limit, the kernel (and a fallback kernel), +the CPU governor, whether `seg game run` may boost the computer, transparent +hugepages, split lock detection, memory compaction and the security +mitigations. Prints PASS, WARN or FAIL for each, and the fix for each FAIL and +WARN. Exits with status 1 when something FAILs. +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + Pass, + Warn, + Fail, + Skip, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Check { + pub name: &'static str, + pub status: Status, + pub detail: String, + pub fix: Option, +} + +fn check( + name: &'static str, + status: Status, + detail: impl Into, + fix: Option<&str>, +) -> Check { + Check { + name, + status, + detail: detail.into(), + fix: fix.map(str::to_string), + } +} + +/// What was found on the computer; `evaluate` only judges it. +#[derive(Debug, Default)] +pub struct Inputs { + pub kernel_release: Option, + pub kernel_package: Option, + /// Kernel images in /boot (`vmlinuz-linux-zen`). + pub kernel_images: Vec, + /// Vulkan devices; None when vulkaninfo is not installed or failed. + pub vulkan_devices: Option>, + pub gpus: Vec, + pub packages: BTreeSet, + pub ntsync_device_mode: Option, + pub max_map_count: Option, + /// (soft, hard); `u64::MAX` for unlimited. + pub nofile: Option<(u64, u64)>, + pub governor: Option, + pub is_root: bool, + pub in_gamemode_group: bool, + /// Whether sudo lets this user run the boost helper without a password + /// (`sudo -n -l ...`); None when it was not asked. + pub boost_allowed: Option, + pub thp: Option, + pub split_lock_mitigate: Option, + pub compaction_proactiveness: Option, + pub cmdline: Option, + pub cpu: Option, + /// `amd-pstate-epp`, `acpi-cpufreq`, `intel_pstate`, ... + pub scaling_driver: Option, +} + +/// `deviceName` lines of `vulkaninfo --summary`. +pub fn parse_vulkan_devices(output: &str) -> Vec { + output + .lines() + .filter_map(|l| { + let l = l.trim(); + let rest = l.strip_prefix("deviceName")?; + Some(rest.trim_start().strip_prefix('=')?.trim().to_string()) + }) + .collect() +} + +/// `/proc/PID/limits`: the soft and hard value of "Max open files". +pub fn parse_nofile(limits: &str) -> Option<(u64, u64)> { + let line = limits.lines().find(|l| l.starts_with("Max open files"))?; + let mut f = line.trim_start_matches("Max open files").split_whitespace(); + let num = |w: &str| { + if w == "unlimited" { + Some(u64::MAX) + } else { + w.parse().ok() + } + }; + Some((num(f.next()?)?, num(f.next()?)?)) +} + +/// The supplementary group ids in /proc/PID/status. +pub fn parse_groups(status: &str) -> Vec { + status + .lines() + .find_map(|l| l.strip_prefix("Groups:")) + .map(|g| { + g.split_whitespace() + .filter_map(|w| w.parse().ok()) + .collect() + }) + .unwrap_or_default() +} + +pub fn group_gid(group_file: &str, name: &str) -> Option { + group_file.lines().find_map(|l| { + let mut f = l.split(':'); + (f.next()? == name).then(|| f.nth(1)?.parse().ok())? + }) +} + +/// Package names from pacman's local database directories (`bash-5.2-1`). +pub fn installed_packages(local_db: &Path) -> BTreeSet { + let Ok(entries) = fs::read_dir(local_db) else { + return BTreeSet::new(); + }; + entries + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let mut parts = name.rsplitn(3, '-'); + parts.next()?; // pkgrel + parts.next()?; // pkgver + Some(parts.next()?.to_string()) + }) + .collect() +} + +/// The 32-bit packages a GPU needs for 32-bit games (Steam is 32-bit). +pub fn lib32_packages(vendor: GpuVendor) -> &'static [&'static str] { + match vendor { + GpuVendor::Amd => &[ + "lib32-mesa", + "lib32-vulkan-radeon", + "lib32-vulkan-icd-loader", + ], + GpuVendor::Intel => &[ + "lib32-mesa", + "lib32-vulkan-intel", + "lib32-vulkan-icd-loader", + ], + GpuVendor::Nvidia => &["lib32-nvidia-utils", "lib32-vulkan-icd-loader"], + GpuVendor::Other => &[], + } +} + +fn kernel_is_gaming_flavour(release: &str) -> bool { + let r = release.to_lowercase(); + ["zen", "xanmod", "cachyos", "liquorix", "tkg"] + .iter() + .any(|f| r.contains(f)) +} + +pub fn evaluate(i: &Inputs) -> Vec { + use Status::*; + let mut out = Vec::new(); + + // Vulkan + out.push(match &i.vulkan_devices { + None => check( + "vulkan", + Fail, + "vulkaninfo did not run (not installed, or no Vulkan driver)", + Some("sudo pacman -S vulkan-tools vulkan-icd-loader (then: sudo seg game setup)"), + ), + Some(devices) => { + let real: Vec<&String> = devices + .iter() + .filter(|d| !d.to_lowercase().contains("llvmpipe") && !d.to_lowercase().contains("lavapipe")) + .collect(); + if let Some(first) = real.first() { + check("vulkan", Pass, first.as_str(), None) + } else if devices.is_empty() { + check("vulkan", Fail, "vulkaninfo lists no device", Some("sudo seg game setup --yes")) + } else { + check( + "vulkan", + Fail, + "only the software renderer (llvmpipe) is available: games would run at a crawl", + Some("sudo seg game setup --yes (installs the driver for the GPU found)"), + ) + } + } + }); + + // GPU driver in the kernel + if i.gpus.is_empty() { + out.push(check( + "gpu driver", + Skip, + "no graphics card found in /sys/class/drm (virtual machine?)", + None, + )); + } + for gpu in &i.gpus { + let name = hw::describe(gpu); + let driver = gpu.driver.as_deref(); + out.push(match (gpu.vendor, driver) { + (_, None) => check("gpu driver", Fail, format!("{name}: no kernel driver is bound"), Some("sudo seg game setup --yes (then reboot)")), + (GpuVendor::Amd, Some("amdgpu")) | (GpuVendor::Intel, Some("i915" | "xe")) => { + check("gpu driver", Pass, format!("{name}: {}", driver.unwrap_or("")), None) + } + (GpuVendor::Nvidia, Some("nvidia")) => check("gpu driver", Pass, format!("{name}: nvidia"), None), + (GpuVendor::Nvidia, Some("nouveau")) if gpu.nvidia_open_capable() => check( + "gpu driver", + Fail, + format!("{name}: running on the open-source nouveau driver; NVIDIA's own is much faster for games"), + Some("sudo seg game setup --yes (installs nvidia-open-dkms; then reboot)"), + ), + (GpuVendor::Nvidia, Some("nouveau")) => check( + "gpu driver", + Pass, + format!("{name}: nouveau (this card is too old for NVIDIA's current driver)"), + None, + ), + (_, Some(other)) => check("gpu driver", Warn, format!("{name}: driver {other}"), None), + }); + } + + // 32-bit drivers + let mut missing: BTreeSet<&str> = BTreeSet::new(); + for gpu in &i.gpus { + for pkg in lib32_packages(gpu.vendor) { + if !i.packages.contains(*pkg) { + missing.insert(pkg); + } + } + } + out.push(if i.gpus.is_empty() { + check("32-bit drivers", Skip, "no graphics card found", None) + } else if missing.is_empty() { + check("32-bit drivers", Pass, "the lib32 graphics packages are installed", None) + } else { + let list = missing.iter().copied().collect::>().join(" "); + check( + "32-bit drivers", + Fail, + format!("missing: {list} (Steam and many games are 32-bit; the [lib32] repository must be enabled)"), + Some(&format!("sudo pacman -S {list}")), + ) + }); + + // NTSYNC + let version = i.kernel_release.as_deref().and_then(hw::kernel_version); + out.push(match (version, i.ntsync_device_mode) { + (Some(v), _) if v < (6, 14) => check( + "ntsync", + Fail, + format!("kernel {}.{} is older than 6.14, which added NTSYNC", v.0, v.1), + Some("sudo pacman -S linux-zen linux-zen-headers (then pick it in the boot menu)"), + ), + (_, None) => check( + "ntsync", + Fail, + "/dev/ntsync does not exist: the module is not loaded", + Some("sudo modprobe ntsync (always: echo ntsync | sudo tee /etc/modules-load.d/10-ntsync.conf)"), + ), + (_, Some(mode)) if mode & 0o006 != 0o006 => check( + "ntsync", + Fail, + format!("/dev/ntsync is mode {mode:o}: Wine, running as you, cannot use it"), + Some("sudo chmod 666 /dev/ntsync (always: KERNEL==\"ntsync\", MODE=\"0666\" in /etc/udev/rules.d/70-ntsync.rules)"), + ), + (_, Some(_)) => check("ntsync", Pass, "/dev/ntsync is there and usable by Wine and Proton", None), + }); + + // Memory maps + out.push(match i.max_map_count { + Some(n) if n >= 1_048_576 => check("max_map_count", Pass, n.to_string(), None), + Some(n) => check( + "max_map_count", + Fail, + format!("{n}: some games crash below 1048576"), + Some("sudo sysctl -w vm.max_map_count=2147483642 (always: /etc/sysctl.d/80-seglinux-gaming.conf)"), + ), + None => check("max_map_count", Skip, "cannot read vm.max_map_count", None), + }); + + // Open files + out.push(match i.nofile { + Some((_, hard)) if hard >= 524_288 => check( + "open files", + Pass, + format!("hard limit {}", if hard == u64::MAX { "unlimited".to_string() } else { hard.to_string() }), + None, + ), + Some((_, hard)) => check( + "open files", + Fail, + format!("hard limit {hard}: esync needs 524288 or more"), + Some("add `* hard nofile 1048576` to /etc/security/limits.d/90-seglinux-esync.conf, then log out and in"), + ), + None => check("open files", Skip, "cannot read the limits of this process", None), + }); + + // Kernel + out.push(match (&i.kernel_release, &i.kernel_package) { + (Some(r), pkg) if kernel_is_gaming_flavour(r) => check( + "kernel", + Pass, + format!("{r}{}", pkg.as_deref().map(|p| format!(" ({p})")).unwrap_or_default()), + None, + ), + (Some(r), _) => check( + "kernel", + Warn, + format!("{r}: a stock kernel; linux-zen is tuned for desktop latency"), + Some("sudo pacman -S linux-zen linux-zen-headers && sudo grub-mkconfig -o /boot/grub/grub.cfg (the current kernel stays as a fallback)"), + ), + (None, _) => check("kernel", Skip, "cannot read the kernel release", None), + }); + out.push(if i.kernel_images.len() >= 2 { + check("fallback kernel", Pass, i.kernel_images.join(", "), None) + } else { + check( + "fallback kernel", + Warn, + "only one kernel is installed: if an update breaks it, there is nothing to boot instead", + Some("sudo pacman -S linux && sudo grub-mkconfig -o /boot/grub/grub.cfg (it appears under Advanced options)"), + ) + }); + + // Governor + out.push(match i.governor.as_deref() { + None => check("cpu governor", Skip, "no cpufreq on this machine (virtual machine?)", None), + Some("performance") => check( + "cpu governor", + Warn, + "performance all the time: the CPU runs hot and loud even when idle", + Some("sudo seg tune balanced (seg game run switches to performance only while a game runs)"), + ), + Some(g) => check( + "cpu governor", + Pass, + format!("{g} at rest; performance while a game runs under seg game run"), + None, + ), + }); + + // AMD: the amd-pstate driver in active mode, the one with energy hints. + if i.cpu + .as_ref() + .is_some_and(|c| c.vendor == hw::CpuVendor::Amd) + { + out.push(match i.scaling_driver.as_deref() { + Some("amd-pstate-epp") => check("cpu driver", Pass, "amd-pstate in active mode", None), + Some(other) => check( + "cpu driver", + Warn, + format!("{other}: AMD CPUs from Zen 2 on do better with amd_pstate=active (the CPU picks frequencies itself, guided by the energy hints seg game run sets)"), + Some("add amd_pstate=active to GRUB_CMDLINE_LINUX_DEFAULT in /etc/default/grub, then sudo grub-mkconfig -o /boot/grub/grub.cfg and reboot"), + ), + None => check("cpu driver", Skip, "no cpufreq driver (virtual machine?)", None), + }); + } + + // May seg game run boost? + out.push(if i.is_root { + check("game boost", Pass, "running as root", None) + } else if i.boost_allowed == Some(true) { + check("game boost", Pass, "sudo lets you run the boost helper, as the gamemode group may", None) + } else if !i.packages.contains("segctl") { + check( + "game boost", + Fail, + "segctl is not installed, so there is no sudo rule: seg game run cannot raise the governor", + Some("sudo pacman -S segctl"), + ) + } else if i.in_gamemode_group { + check( + "game boost", + Fail, + "you are in the gamemode group but sudo does not allow the boost helper", + Some("sudo visudo -c (a broken file in /etc/sudoers.d disables the rule; /etc/sudoers.d/seg-game comes from segctl)"), + ) + } else { + check( + "game boost", + Fail, + "you are not in the gamemode group (or have not logged in again since): seg game run cannot raise the governor", + Some("sudo gpasswd -a $USER gamemode (then log out and in)"), + ) + }); + + // Transparent hugepages + out.push(match i.thp.as_deref() { + Some(t) if t.contains("[madvise]") => check("hugepages", Pass, "transparent hugepages: madvise", None), + Some(t) => check( + "hugepages", + Warn, + format!("transparent hugepages: {t}; `always` can cause stalls, `madvise` lets games ask"), + Some("echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled (always: transparent_hugepage=madvise on the kernel command line)"), + ), + None => check("hugepages", Skip, "this kernel has no transparent hugepages", None), + }); + + // Split locks + out.push(match i.split_lock_mitigate { + Some(0) => check("split lock", Pass, "kernel.split_lock_mitigate = 0", None), + Some(n) => check( + "split lock", + Warn, + format!("kernel.split_lock_mitigate = {n}: games that do split locks are slowed down on purpose"), + Some("sudo sysctl -w kernel.split_lock_mitigate=0 (always: /etc/sysctl.d/80-seglinux-gaming.conf)"), + ), + None => check("split lock", Skip, "this kernel has no split lock detection setting", None), + }); + + // Compaction + out.push(match i.compaction_proactiveness { + Some(0) => check("compaction", Pass, "vm.compaction_proactiveness = 0", None), + Some(n) => check( + "compaction", + Warn, + format!("vm.compaction_proactiveness = {n}: the kernel defragments memory in the background, which can stall a game"), + Some("sudo sysctl -w vm.compaction_proactiveness=0 (always: /etc/sysctl.d/80-seglinux-gaming.conf)"), + ), + None => check("compaction", Skip, "this kernel has no vm.compaction_proactiveness", None), + }); + + // Mitigations stay on + out.push(match &i.cmdline { + Some(c) if c.split_whitespace().any(|w| w == "mitigations=off") => check( + "mitigations", + Warn, + "mitigations=off is on the kernel command line: faster, but the CPU's protections against Spectre-style attacks are off", + Some("sudo seg game unsafe-boost off"), + ), + Some(_) => check("mitigations", Pass, "the kernel's default security mitigations are on", None), + None => check("mitigations", Skip, "cannot read /proc/cmdline", None), + }); + out +} + +/// Look at the computer. `live` allows starting programs (vulkaninfo, lspci). +pub fn collect(root: &Path, live: bool) -> Inputs { + let read = |rel: &str| sys::read_trimmed(root.join(rel)); + let num = |rel: &str| read(rel).and_then(|t| t.parse::().ok()); + let release = read("proc/sys/kernel/osrelease"); + let mut images: Vec = fs::read_dir(root.join("boot")) + .map(|d| { + d.flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| n.starts_with("vmlinuz")) + .collect() + }) + .unwrap_or_default(); + images.sort(); + let status = read("proc/self/status").unwrap_or_default(); + let is_root = sys::parse_euid(&status) == Some(0); + let groups = parse_groups(&status); + let gamemode = read("etc/group").and_then(|g| group_gid(&g, "gamemode")); + let vulkan = if live && sys::which("vulkaninfo").is_some() { + sys::run("vulkaninfo", &["--summary"]) + .ok() + .filter(|o| o.ok) + .map(|o| parse_vulkan_devices(&o.stdout)) + } else { + None + }; + Inputs { + kernel_package: hw::kernel_package(root), + kernel_release: release, + kernel_images: images, + vulkan_devices: vulkan, + gpus: hw::gpus(root), + packages: installed_packages(&root.join("var/lib/pacman/local")), + ntsync_device_mode: fs::metadata(root.join("dev/ntsync")) + .ok() + .map(|m| std::os::unix::fs::PermissionsExt::mode(&m.permissions()) & 0o777), + max_map_count: num("proc/sys/vm/max_map_count"), + nofile: read("proc/self/limits").and_then(|l| parse_nofile(&l)), + governor: read("sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"), + is_root, + in_gamemode_group: gamemode.is_some_and(|g| groups.contains(&g)), + boost_allowed: if live && sys::which("sudo").is_some() { + sys::run( + "sudo", + &["-n", "-l", "/usr/bin/seg", "game", "boost", "off", "1"], + ) + .ok() + .map(|o| o.ok) + } else { + None + }, + thp: read("sys/kernel/mm/transparent_hugepage/enabled"), + split_lock_mitigate: num("proc/sys/kernel/split_lock_mitigate"), + compaction_proactiveness: num("proc/sys/vm/compaction_proactiveness"), + cmdline: read("proc/cmdline"), + cpu: Some(hw::cpu(root)), + scaling_driver: read("sys/devices/system/cpu/cpu0/cpufreq/scaling_driver"), + } +} + +pub fn label(s: Status) -> &'static str { + match s { + Status::Pass => "PASS", + Status::Warn => "WARN", + Status::Fail => "FAIL", + Status::Skip => "SKIP", + } +} + +pub fn render(checks: &[Check]) -> String { + let mut text = String::new(); + for c in checks { + text.push_str(&format!( + "[{}] {:<15} {}\n", + label(c.status), + c.name, + c.detail + )); + if let (Status::Fail | Status::Warn, Some(fix)) = (c.status, &c.fix) { + text.push_str(&format!(" {:<15} fix: {fix}\n", "")); + } + } + let count = |s| checks.iter().filter(|c| c.status == s).count(); + text.push_str(&format!( + "\n{} passed, {} to improve, {} failed\n", + count(Status::Pass), + count(Status::Warn), + count(Status::Fail) + )); + text +} + +pub fn run() -> Result { + let inputs = collect(Path::new("/"), true); + let checks = evaluate(&inputs); + out!("{}", render(&checks)); + Ok(u8::from(checks.iter().any(|c| c.status == Status::Fail))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::hw::{fixture, gpus}; + + fn pkgs(names: &[&str]) -> BTreeSet { + names.iter().map(|s| s.to_string()).collect() + } + + /// A machine where everything is right. + fn good() -> Inputs { + Inputs { + kernel_release: Some("7.2.11-zen1-1-zen".into()), + kernel_package: Some("linux-zen".into()), + kernel_images: vec!["vmlinuz-linux".into(), "vmlinuz-linux-zen".into()], + vulkan_devices: Some(vec!["AMD Radeon RX 7900 XTX (RADV NAVI31)".into()]), + gpus: gpus(&fixture("gpu-amd")), + packages: pkgs(&[ + "lib32-mesa", + "lib32-vulkan-radeon", + "lib32-vulkan-icd-loader", + "segctl", + ]), + ntsync_device_mode: Some(0o666), + max_map_count: Some(2_147_483_642), + nofile: Some((1024, 1_048_576)), + governor: Some("powersave".into()), + is_root: false, + in_gamemode_group: true, + boost_allowed: Some(true), + thp: Some("always [madvise] never".into()), + split_lock_mitigate: Some(0), + compaction_proactiveness: Some(0), + cmdline: Some("root=/dev/sda2 quiet".into()), + cpu: Some(hw::cpu(&fixture("ryzen-7950x3d"))), + scaling_driver: Some("amd-pstate-epp".into()), + } + } + + fn status(checks: &[Check], name: &str) -> Vec { + checks + .iter() + .filter(|c| c.name == name) + .map(|c| c.status) + .collect() + } + + #[test] + fn a_ready_machine_passes_everything() { + let checks = evaluate(&good()); + for c in &checks { + assert_eq!(c.status, Status::Pass, "{} -> {}", c.name, c.detail); + assert!(c.fix.is_none()); + } + assert_eq!( + checks.len(), + 15, + "{:?}", + checks.iter().map(|c| c.name).collect::>() + ); + let text = render(&checks); + assert!(text.contains("[PASS] vulkan")); + assert!(text.contains("15 passed, 0 to improve, 0 failed"), "{text}"); + } + + #[test] + fn software_rendering_is_a_failure_with_the_fix() { + let mut i = good(); + i.vulkan_devices = Some(vec!["llvmpipe (LLVM 19.1.7, 256 bits)".into()]); + let c = evaluate(&i); + assert_eq!(status(&c, "vulkan"), [Status::Fail]); + assert!( + c[0].fix + .as_deref() + .unwrap() + .contains("seg game setup --yes") + ); + i.vulkan_devices = None; + assert!( + evaluate(&i)[0] + .fix + .as_deref() + .unwrap() + .contains("vulkan-tools") + ); + i.vulkan_devices = Some(vec![]); + assert_eq!(status(&evaluate(&i), "vulkan"), [Status::Fail]); + } + + #[test] + fn missing_32_bit_drivers_are_named() { + let mut i = good(); + i.packages = pkgs(&["lib32-mesa"]); + let c = evaluate(&i); + let d = c.iter().find(|c| c.name == "32-bit drivers").unwrap(); + assert_eq!(d.status, Status::Fail); + assert!( + d.detail + .contains("lib32-vulkan-icd-loader lib32-vulkan-radeon"), + "{}", + d.detail + ); + assert_eq!( + d.fix.as_deref(), + Some("sudo pacman -S lib32-vulkan-icd-loader lib32-vulkan-radeon") + ); + } + + #[test] + fn nvidia_needs_its_own_driver_and_its_lib32_utils() { + let mut i = good(); + i.gpus = gpus(&fixture("gpu-nvidia-ada-nouveau")); + i.packages = pkgs(&[]); + let c = evaluate(&i); + assert_eq!(status(&c, "gpu driver"), [Status::Fail]); + let d = c.iter().find(|c| c.name == "32-bit drivers").unwrap(); + assert!(d.detail.contains("lib32-nvidia-utils"), "{}", d.detail); + // The proprietary driver loaded: fine. + i.gpus = gpus(&fixture("gpu-nvidia-ada")); + assert_eq!(status(&evaluate(&i), "gpu driver"), [Status::Pass]); + // An old card cannot have it: nouveau is the right driver. + i.gpus = gpus(&fixture("gpu-nvidia-pascal")); + assert_eq!(status(&evaluate(&i), "gpu driver"), [Status::Pass]); + } + + #[test] + fn a_hybrid_laptop_is_checked_card_by_card() { + let mut i = good(); + i.gpus = gpus(&fixture("gpu-hybrid-laptop")); + i.packages = pkgs(&[ + "lib32-mesa", + "lib32-vulkan-intel", + "lib32-vulkan-icd-loader", + "lib32-nvidia-utils", + ]); + let c = evaluate(&i); + assert_eq!(status(&c, "gpu driver"), [Status::Pass, Status::Pass]); + assert_eq!(status(&c, "32-bit drivers"), [Status::Pass]); + } + + #[test] + fn no_gpu_skips_the_gpu_questions() { + let mut i = good(); + i.gpus = vec![]; + let c = evaluate(&i); + assert_eq!(status(&c, "gpu driver"), [Status::Skip]); + assert_eq!(status(&c, "32-bit drivers"), [Status::Skip]); + } + + #[test] + fn ntsync_has_three_ways_to_fail() { + let mut i = good(); + i.ntsync_device_mode = None; + let c = evaluate(&i); + let n = c.iter().find(|c| c.name == "ntsync").unwrap(); + assert_eq!(n.status, Status::Fail); + assert!( + n.fix + .as_deref() + .unwrap() + .starts_with("sudo modprobe ntsync") + ); + i.ntsync_device_mode = Some(0o600); + let n = evaluate(&i) + .into_iter() + .find(|c| c.name == "ntsync") + .unwrap(); + assert!(n.fix.as_deref().unwrap().contains("chmod 666")); + i.kernel_release = Some("6.12.4-arch1-1".into()); + let n = evaluate(&i) + .into_iter() + .find(|c| c.name == "ntsync") + .unwrap(); + assert!(n.detail.contains("older than 6.14"), "{}", n.detail); + } + + #[test] + fn sysctls_and_limits() { + let mut i = good(); + i.max_map_count = Some(65530); + i.nofile = Some((1024, 4096)); + i.split_lock_mitigate = Some(1); + i.compaction_proactiveness = Some(20); + let c = evaluate(&i); + assert_eq!(status(&c, "max_map_count"), [Status::Fail]); + assert_eq!(status(&c, "open files"), [Status::Fail]); + assert_eq!(status(&c, "split lock"), [Status::Warn]); + assert_eq!(status(&c, "compaction"), [Status::Warn]); + let text = render(&c); + assert!( + text.contains("fix: sudo sysctl -w vm.max_map_count=2147483642"), + "{text}" + ); + assert!(text.contains("limits.d/90-seglinux-esync.conf"), "{text}"); + // Older kernels simply lack the keys. + i.split_lock_mitigate = None; + i.compaction_proactiveness = None; + let c = evaluate(&i); + assert_eq!(status(&c, "split lock"), [Status::Skip]); + assert_eq!(status(&c, "compaction"), [Status::Skip]); + } + + #[test] + fn kernels() { + let mut i = good(); + i.kernel_release = Some("6.14.2-arch1-1".into()); + i.kernel_images = vec!["vmlinuz-linux".into()]; + let c = evaluate(&i); + assert_eq!(status(&c, "kernel"), [Status::Warn]); + assert_eq!(status(&c, "fallback kernel"), [Status::Warn]); + let text = render(&c); + assert!( + text.contains("pacman -S linux-zen linux-zen-headers"), + "{text}" + ); + assert!(text.contains("grub-mkconfig"), "{text}"); + } + + #[test] + fn amd_cpus_want_the_active_pstate_driver_and_others_are_not_asked() { + let mut i = good(); + i.scaling_driver = Some("acpi-cpufreq".into()); + let c = evaluate(&i); + assert_eq!(status(&c, "cpu driver"), [Status::Warn]); + assert!(render(&c).contains("amd_pstate=active")); + i.scaling_driver = Some("amd-pstate".into()); + assert_eq!(status(&evaluate(&i), "cpu driver"), [Status::Warn]); + i.scaling_driver = None; + assert_eq!(status(&evaluate(&i), "cpu driver"), [Status::Skip]); + i.cpu = Some(hw::cpu(&fixture("intel-12700"))); + i.scaling_driver = Some("intel_pstate".into()); + assert!(status(&evaluate(&i), "cpu driver").is_empty()); + } + + #[test] + fn the_governor_is_judged_at_rest() { + let mut i = good(); + i.governor = Some("performance".into()); + assert_eq!(status(&evaluate(&i), "cpu governor"), [Status::Warn]); + i.governor = None; + assert_eq!(status(&evaluate(&i), "cpu governor"), [Status::Skip]); + } + + #[test] + fn who_may_boost() { + let mut i = good(); + i.boost_allowed = Some(false); + i.in_gamemode_group = false; + let b = evaluate(&i) + .into_iter() + .find(|c| c.name == "game boost") + .unwrap(); + assert_eq!(b.status, Status::Fail); + assert!( + b.fix + .as_deref() + .unwrap() + .contains("gpasswd -a $USER gamemode") + ); + // In the group and still refused: a sudoers problem. + i.in_gamemode_group = true; + let b = evaluate(&i) + .into_iter() + .find(|c| c.name == "game boost") + .unwrap(); + assert!( + b.fix.as_deref().unwrap().contains("visudo -c"), + "{:?}", + b.fix + ); + // Not asked (not a live check): the group decides what is said. + i.boost_allowed = None; + assert_eq!(status(&evaluate(&i), "game boost"), [Status::Fail]); + // No segctl, no rule. + i.packages.remove("segctl"); + let b = evaluate(&i) + .into_iter() + .find(|c| c.name == "game boost") + .unwrap(); + assert!(b.fix.as_deref().unwrap().contains("pacman -S segctl")); + i.is_root = true; + assert_eq!(status(&evaluate(&i), "game boost"), [Status::Pass]); + } + + #[test] + fn hugepages_and_mitigations() { + let mut i = good(); + i.thp = Some("[always] madvise never".into()); + i.cmdline = Some("quiet mitigations=off".into()); + let c = evaluate(&i); + assert_eq!(status(&c, "hugepages"), [Status::Warn]); + assert_eq!(status(&c, "mitigations"), [Status::Warn]); + assert!(render(&c).contains("seg game unsafe-boost off")); + // A word that only contains the text is not the setting. + i.cmdline = Some("quiet nomitigations=off2".into()); + assert_eq!(status(&evaluate(&i), "mitigations"), [Status::Pass]); + } + + #[test] + fn the_exit_status_follows_the_failures_only() { + let mut i = good(); + i.thp = Some("[always] madvise never".into()); + assert!(!evaluate(&i).iter().any(|c| c.status == Status::Fail)); + i.max_map_count = Some(1); + assert!(evaluate(&i).iter().any(|c| c.status == Status::Fail)); + } + + #[test] + fn parsers() { + let vk = "Devices:\n========\nGPU0:\n\tapiVersion = 1.3.296\n\tdeviceName = AMD Radeon RX 7900 XTX (RADV NAVI31)\n\tdriverName = radv\nGPU1:\n\tdeviceName = llvmpipe (LLVM 19.1.7, 256 bits)\n"; + assert_eq!( + parse_vulkan_devices(vk), + [ + "AMD Radeon RX 7900 XTX (RADV NAVI31)", + "llvmpipe (LLVM 19.1.7, 256 bits)" + ] + ); + let limits = "Limit Soft Limit Hard Limit Units\nMax cpu time unlimited unlimited seconds\nMax open files 1024 1048576 files\n"; + assert_eq!(parse_nofile(limits), Some((1024, 1_048_576))); + assert_eq!( + parse_nofile("Max open files unlimited unlimited files"), + Some((u64::MAX, u64::MAX)) + ); + assert_eq!(parse_nofile(""), None); + assert_eq!( + parse_groups("Name:\tx\nGroups:\t10 1000 998\n"), + [10, 1000, 998] + ); + assert_eq!(parse_groups("Groups:\t\n"), Vec::::new()); + assert_eq!( + group_gid("root:x:0:\ngamemode:x:998:alice\n", "gamemode"), + Some(998) + ); + assert_eq!(group_gid("root:x:0:\n", "gamemode"), None); + } + + #[test] + fn installed_packages_come_from_the_database_folder_names() { + let t = sys::testutil::TempDir::new(); + for n in [ + "lib32-mesa-25.0.1-1", + "linux-zen-headers-7.2.11.zen1-1", + "bash-5.2-1", + "x", + ] { + t.put(&format!("{n}/desc"), ""); + } + t.put("ALPM_DB_VERSION", "9\n"); + let p = installed_packages(t.path()); + assert!(p.contains("lib32-mesa") && p.contains("linux-zen-headers") && p.contains("bash")); + assert!(!p.contains("x")); + assert_eq!(p.len(), 3); + } + + #[test] + fn collecting_from_a_fake_root() { + let t = sys::testutil::TempDir::new(); + t.put("proc/sys/kernel/osrelease", "7.2.11-zen1-1-zen\n"); + t.put("proc/sys/vm/max_map_count", "2147483642\n"); + t.put("proc/sys/kernel/split_lock_mitigate", "0\n"); + t.put( + "sys/devices/system/cpu/cpu0/cpufreq/scaling_governor", + "powersave\n", + ); + t.put( + "sys/kernel/mm/transparent_hugepage/enabled", + "always [madvise] never\n", + ); + t.put("boot/vmlinuz-linux", ""); + t.put("boot/vmlinuz-linux-zen", ""); + t.put("boot/initramfs-linux.img", ""); + t.put("proc/cmdline", "quiet\n"); + t.put( + "proc/cpuinfo", + "vendor_id\t: AuthenticAMD\nmodel name\t: AMD Ryzen 7 5800X\n", + ); + t.put( + "sys/devices/system/cpu/cpu0/cpufreq/scaling_driver", + "amd-pstate-epp\n", + ); + let i = collect(t.path(), false); + assert_eq!(i.kernel_images, ["vmlinuz-linux", "vmlinuz-linux-zen"]); + assert_eq!(i.max_map_count, Some(2_147_483_642)); + assert_eq!(i.split_lock_mitigate, Some(0)); + assert_eq!(i.governor.as_deref(), Some("powersave")); + assert_eq!(i.scaling_driver.as_deref(), Some("amd-pstate-epp")); + assert_eq!(i.cpu.map(|c| c.vendor), Some(hw::CpuVendor::Amd)); + assert_eq!(i.boost_allowed, None, "sudo is only asked in a live check"); + assert_eq!( + i.vulkan_devices, None, + "no programs are started when not live" + ); + assert_eq!(i.ntsync_device_mode, None); + assert_eq!(i.compaction_proactiveness, None); + } +} diff --git a/tools/seg/segctl/src/game/dbus.rs b/tools/seg/segctl/src/game/dbus.rs new file mode 100644 index 0000000..37db50a --- /dev/null +++ b/tools/seg/segctl/src/game/dbus.rs @@ -0,0 +1,442 @@ +//! Keeps the screen from blinking out during a game: a minimal D-Bus client, +//! just enough to call `org.freedesktop.ScreenSaver.Inhibit` on the session +//! bus and hold the connection open (the inhibition ends when it closes). +//! Plasma's PowerDevil and screen locker, GNOME and others implement that +//! interface. No D-Bus library: a method call is a few dozen bytes. + +use std::io::{Read, Write}; +use std::os::linux::net::SocketAddrExt; +use std::os::unix::net::{SocketAddr, UnixStream}; +use std::time::Duration; + +use crate::error::{Error, Result}; + +/// Where the session bus listens, from DBUS_SESSION_BUS_ADDRESS +/// (`unix:path=/run/user/1000/bus` or `unix:abstract=/tmp/dbus-AbC`). +#[derive(Debug, PartialEq, Eq)] +pub enum BusAddress { + Path(String), + Abstract(String), +} + +pub fn parse_address(text: &str) -> Option { + text.split(';').find_map(|entry| { + let rest = entry.strip_prefix("unix:")?; + rest.split(',').find_map(|kv| { + let (k, v) = kv.split_once('=')?; + let v = unescape(v); + match k { + "path" => Some(BusAddress::Path(v)), + "abstract" => Some(BusAddress::Abstract(v)), + _ => None, + } + }) + }) +} + +/// `%2f` style escapes of D-Bus addresses. +fn unescape(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while let Some(&b) = bytes.get(i) { + if b == b'%' + && let Some(hex) = text.get(i + 1..i + 3) + && let Ok(v) = u8::from_str_radix(hex, 16) + { + out.push(v); + i += 3; + } else { + out.push(b); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn pad(buf: &mut Vec, align: usize) { + buf.resize(buf.len().next_multiple_of(align), 0); +} + +fn put_u32(buf: &mut Vec, v: u32) { + pad(buf, 4); + buf.extend_from_slice(&v.to_le_bytes()); +} + +fn put_str(buf: &mut Vec, s: &str) { + put_u32(buf, s.len() as u32); + buf.extend_from_slice(s.as_bytes()); + buf.push(0); +} + +/// One header field: `(code, variant)` with a string-like value. +fn put_field(buf: &mut Vec, code: u8, kind: u8, value: &str) { + pad(buf, 8); + buf.push(code); + buf.extend_from_slice(&[1, kind, 0]); + put_str(buf, value); +} + +/// A METHOD_CALL message. `signature` and `body` go together (`"ss"` and +/// two marshalled strings), or are both empty. +pub fn method_call( + serial: u32, + destination: &str, + path: &str, + interface: &str, + member: &str, + signature: &str, + body: &[u8], +) -> Vec { + let mut fields = Vec::new(); + put_field(&mut fields, 1, b'o', path); + put_field(&mut fields, 6, b's', destination); + put_field(&mut fields, 2, b's', interface); + put_field(&mut fields, 3, b's', member); + if !signature.is_empty() { + pad(&mut fields, 8); + fields.push(8); + fields.extend_from_slice(&[1, b'g', 0]); + fields.push(signature.len() as u8); + fields.extend_from_slice(signature.as_bytes()); + fields.push(0); + } + let mut msg = vec![b'l', 1, 0, 1]; + msg.extend_from_slice(&(body.len() as u32).to_le_bytes()); + msg.extend_from_slice(&serial.to_le_bytes()); + msg.extend_from_slice(&(fields.len() as u32).to_le_bytes()); + msg.extend_from_slice(&fields); + pad(&mut msg, 8); + msg.extend_from_slice(body); + msg +} + +pub fn hello(serial: u32) -> Vec { + method_call( + serial, + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "Hello", + "", + &[], + ) +} + +pub fn inhibit(serial: u32, application: &str, reason: &str) -> Vec { + let mut body = Vec::new(); + put_str(&mut body, application); + put_str(&mut body, reason); + method_call( + serial, + "org.freedesktop.ScreenSaver", + "/org/freedesktop/ScreenSaver", + "org.freedesktop.ScreenSaver", + "Inhibit", + "ss", + &body, + ) +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Reply { + /// 2 method return, 3 error, 4 signal. + pub kind: u8, + pub reply_serial: Option, + pub error_name: Option, + pub body: Vec, +} + +fn get_u32(data: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(data.get(at..at + 4)?.try_into().ok()?)) +} + +fn align(at: usize, to: usize) -> usize { + at.div_ceil(to) * to +} + +/// A string-like value at `at` (aligned): its text and where it ends. +fn read_str(data: &[u8], at: usize) -> Option<(String, usize)> { + let at = align(at, 4); + let len = get_u32(data, at)? as usize; + let text = data.get(at + 4..at + 4 + len)?; + Some((String::from_utf8_lossy(text).into_owned(), at + 4 + len + 1)) +} + +/// Parse the part of a message after its first 16 bytes' fixed header: +/// `fields` is the header-field array, `body` the rest. +pub fn parse_reply(kind: u8, fields: &[u8], body: Vec) -> Option { + let mut reply = Reply { + kind, + reply_serial: None, + error_name: None, + body, + }; + // The array starts at offset 16 of the message, a multiple of 8, so + // offsets inside `fields` have the same alignment as in the message. + let mut at = 0; + while at < fields.len() { + at = align(at, 8); + if at >= fields.len() { + break; + } + let code = *fields.get(at)?; + let sig_len = *fields.get(at + 1)? as usize; + let sig = fields.get(at + 2..at + 2 + sig_len)?; + at += 2 + sig_len + 1; + match sig { + b"u" => { + let v = get_u32(fields, align(at, 4))?; + at = align(at, 4) + 4; + if code == 5 { + reply.reply_serial = Some(v); + } + } + b"s" | b"o" => { + let (s, end) = read_str(fields, at)?; + at = end; + if code == 4 { + reply.error_name = Some(s); + } + } + b"g" => { + let len = *fields.get(at)? as usize; + at += 1 + len + 1; + } + _ => return None, + } + } + Some(reply) +} + +fn read_message(stream: &mut UnixStream) -> std::io::Result> { + let mut fixed = [0u8; 16]; + stream.read_exact(&mut fixed)?; + if fixed[0] != b'l' { + return Ok(None); + } + let body_len = u32::from_le_bytes([fixed[4], fixed[5], fixed[6], fixed[7]]) as usize; + let fields_len = u32::from_le_bytes([fixed[12], fixed[13], fixed[14], fixed[15]]) as usize; + if body_len > 1 << 20 || fields_len > 1 << 16 { + return Ok(None); + } + let mut fields = vec![0u8; fields_len]; + stream.read_exact(&mut fields)?; + let padding = align(fields_len, 8) - fields_len; + let mut skip = vec![0u8; padding]; + stream.read_exact(&mut skip)?; + let mut body = vec![0u8; body_len]; + stream.read_exact(&mut body)?; + Ok(parse_reply(fixed[1], &fields, body)) +} + +/// An open inhibition: dropping it closes the connection, which ends it. +pub struct Inhibitor { + _stream: UnixStream, +} + +fn connect(address: &BusAddress) -> std::io::Result { + match address { + BusAddress::Path(p) => UnixStream::connect(p), + BusAddress::Abstract(name) => { + UnixStream::connect_addr(&SocketAddr::from_abstract_name(name.as_bytes())?) + } + } +} + +fn bus_error(e: impl std::fmt::Display) -> Error { + Error::other(format!("session bus: {e}")) +} + +/// Authenticate and say Hello, and return the stream. +fn open(address: &BusAddress, uid: u32) -> Result { + let mut stream = connect(address).map_err(bus_error)?; + let timeout = Some(Duration::from_secs(2)); + stream.set_read_timeout(timeout).map_err(bus_error)?; + stream.set_write_timeout(timeout).map_err(bus_error)?; + let hex: String = uid + .to_string() + .bytes() + .map(|b| format!("{b:02x}")) + .collect(); + stream + .write_all(format!("\0AUTH EXTERNAL {hex}\r\n").as_bytes()) + .map_err(bus_error)?; + // The answer is one line: "OK ". + let mut line = Vec::new(); + let mut byte = [0u8; 1]; + while !line.ends_with(b"\r\n") { + stream.read_exact(&mut byte).map_err(bus_error)?; + line.push(byte[0]); + if line.len() > 256 { + return Err(bus_error("the bus answered nonsense")); + } + } + if !line.starts_with(b"OK") { + return Err(bus_error("authentication was refused")); + } + stream.write_all(b"BEGIN\r\n").map_err(bus_error)?; + stream.write_all(&hello(1)).map_err(bus_error)?; + wait_for(&mut stream, 1)?; + Ok(stream) +} + +/// Read messages until the reply to `serial` arrives (signals such as +/// NameAcquired come first). +fn wait_for(stream: &mut UnixStream, serial: u32) -> Result { + for _ in 0..16 { + let message = read_message(stream).map_err(bus_error)?; + if let Some(r) = message + && r.reply_serial == Some(serial) + { + return match r.kind { + 2 => Ok(r), + _ => Err(bus_error(r.error_name.unwrap_or_else(|| "an error".into()))), + }; + } + } + Err(bus_error("no answer")) +} + +/// Ask the screen saver not to start, for `reason`. +pub fn inhibit_screensaver(application: &str, reason: &str) -> Result { + let address = std::env::var("DBUS_SESSION_BUS_ADDRESS") + .ok() + .and_then(|a| parse_address(&a)) + .or_else(|| { + let uid = crate::sys::euid().ok()?; + let path = format!("/run/user/{uid}/bus"); + std::path::Path::new(&path) + .exists() + .then_some(BusAddress::Path(path)) + }) + .ok_or_else(|| Error::other("no session bus (DBUS_SESSION_BUS_ADDRESS is not set)"))?; + let mut stream = open(&address, crate::sys::euid()?)?; + stream + .write_all(&inhibit(2, application, reason)) + .map_err(bus_error)?; + let reply = wait_for(&mut stream, 2)?; + // The answer carries a cookie; an answer without one is not an inhibition. + get_u32(&reply.body, 0).ok_or_else(|| bus_error("the answer has no cookie"))?; + Ok(Inhibitor { _stream: stream }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn addresses() { + assert_eq!( + parse_address("unix:path=/run/user/1000/bus"), + Some(BusAddress::Path("/run/user/1000/bus".into())) + ); + assert_eq!( + parse_address("unix:abstract=/tmp/dbus-AbC123,guid=0123"), + Some(BusAddress::Abstract("/tmp/dbus-AbC123".into())) + ); + assert_eq!( + parse_address("tcp:host=x,port=1;unix:path=/a%20b/bus"), + Some(BusAddress::Path("/a b/bus".into())) + ); + assert_eq!(parse_address("tcp:host=x"), None); + assert_eq!(parse_address(""), None); + } + + #[test] + fn hello_is_the_message_the_spec_shows() { + // The 128 bytes dbus-daemon expects for Hello: little endian, a + // method call, no body, serial 1, 110 bytes of header fields. + let m = hello(1); + assert_eq!(m.len(), 128); + assert_eq!( + &m[..16], + &[b'l', 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 110, 0, 0, 0] + ); + // PATH, then DESTINATION, INTERFACE and MEMBER, each on an 8-byte boundary. + assert_eq!(&m[16..20], &[1, 1, b'o', 0]); + assert_eq!(&m[20..24], &[21, 0, 0, 0]); + assert_eq!(&m[24..45], b"/org/freedesktop/DBus"); + assert_eq!(&m[48..52], &[6, 1, b's', 0]); + assert_eq!(&m[112..116], &[3, 1, b's', 0]); + assert_eq!(&m[116..120], &[5, 0, 0, 0]); + assert_eq!(&m[120..126], b"Hello\0"); + assert_eq!(&m[126..], &[0, 0]); + } + + #[test] + fn inhibit_carries_two_strings_and_their_signature() { + let m = inhibit(2, "seg", "playing a game"); + // body: 4+3+1 (padded to 4) = 8, then 4+14+1 = 19 + let body_len = u32::from_le_bytes([m[4], m[5], m[6], m[7]]) as usize; + assert_eq!(body_len, 8 + 19); + let body = &m[m.len() - body_len..]; + assert_eq!(&body[..4], &[3, 0, 0, 0]); + assert_eq!(&body[4..8], b"seg\0"); + assert_eq!(&body[8..12], &[14, 0, 0, 0]); + assert_eq!(&body[12..27], b"playing a game\0"); + // The header ends 8-aligned, so the body starts aligned. + assert_eq!((m.len() - body_len) % 8, 0); + let text = String::from_utf8_lossy(&m); + assert!(text.contains("org.freedesktop.ScreenSaver")); + assert!(text.contains("Inhibit")); + assert!(m.windows(3).any(|w| w == [b's', b's', 0]), "signature ss"); + } + + /// Fields of a METHOD_RETURN as dbus-daemon sends them: REPLY_SERIAL=2, + /// DESTINATION=":1.5", SENDER="org.freedesktop.ScreenSaver", SIGNATURE=u. + fn return_fields() -> Vec { + let mut f = Vec::new(); + f.extend_from_slice(&[5, 1, b'u', 0]); + f.extend_from_slice(&2u32.to_le_bytes()); + put_field(&mut f, 6, b's', ":1.5"); + put_field(&mut f, 7, b's', "org.freedesktop.ScreenSaver"); + pad(&mut f, 8); + f.extend_from_slice(&[8, 1, b'g', 0, 1, b'u', 0]); + f + } + + #[test] + fn a_method_return_gives_its_serial_and_cookie() { + let r = parse_reply(2, &return_fields(), 4242u32.to_le_bytes().to_vec()).unwrap(); + assert_eq!(r.kind, 2); + assert_eq!(r.reply_serial, Some(2)); + assert_eq!(r.error_name, None); + assert_eq!(get_u32(&r.body, 0), Some(4242)); + } + + #[test] + fn an_error_gives_its_name() { + let mut f = Vec::new(); + put_field(&mut f, 4, b's', "org.freedesktop.DBus.Error.ServiceUnknown"); + pad(&mut f, 8); + f.extend_from_slice(&[5, 1, b'u', 0]); + f.extend_from_slice(&2u32.to_le_bytes()); + let r = parse_reply(3, &f, vec![]).unwrap(); + assert_eq!(r.kind, 3); + assert_eq!(r.reply_serial, Some(2)); + assert_eq!( + r.error_name.as_deref(), + Some("org.freedesktop.DBus.Error.ServiceUnknown") + ); + } + + #[test] + fn damaged_headers_are_not_a_panic() { + assert!(parse_reply(2, &[5, 1], vec![]).is_none()); + assert!(parse_reply(2, &[5, 1, b'q', 0, 0, 0, 0, 0], vec![]).is_none()); + assert_eq!( + parse_reply(2, &[], vec![]).map(|r| r.reply_serial), + Some(None) + ); + // A string that claims to be longer than the data. + assert!(parse_reply(3, &[4, 1, b's', 0, 99, 0, 0, 0, b'x', 0], vec![]).is_none()); + } + + #[test] + fn without_a_bus_it_is_an_error_not_a_hang() { + let r = open(&BusAddress::Path("/definitely/not/a/socket".into()), 1000); + assert!(r.is_err()); + assert!(r.err().unwrap().to_string().starts_with("session bus: ")); + } +} diff --git a/tools/seg/segctl/src/game/hw.rs b/tools/seg/segctl/src/game/hw.rs new file mode 100644 index 0000000..c057b79 --- /dev/null +++ b/tools/seg/segctl/src/game/hw.rs @@ -0,0 +1,504 @@ +//! What the computer is: its CPU (and whether one of its cache complexes is +//! the big V-Cache one of an X3D part) and its graphics cards, read from +//! /proc and /sys below a root directory. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::sys; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CpuVendor { + Amd, + Intel, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cpu { + pub vendor: CpuVendor, + pub model: String, +} + +/// `vendor_id` and `model name` of the first processor in /proc/cpuinfo. +pub fn parse_cpuinfo(text: &str) -> Cpu { + let field = |key: &str| { + text.lines().find_map(|l| { + let (k, v) = l.split_once(':')?; + (k.trim() == key).then(|| v.trim().to_string()) + }) + }; + let vendor = match field("vendor_id").as_deref() { + Some("AuthenticAMD") => CpuVendor::Amd, + Some("GenuineIntel") => CpuVendor::Intel, + _ => CpuVendor::Other, + }; + Cpu { + vendor, + model: field("model name").unwrap_or_default(), + } +} + +pub fn cpu(root: &Path) -> Cpu { + parse_cpuinfo(&sys::read_trimmed(root.join("proc/cpuinfo")).unwrap_or_default()) +} + +/// `0-7,16-23` -> the CPU numbers. Anything malformed gives None. +pub fn parse_cpulist(text: &str) -> Option> { + let mut cpus = Vec::new(); + for part in text.trim().split(',') { + match part.split_once('-') { + Some((a, b)) => { + let (a, b): (usize, usize) = (a.trim().parse().ok()?, b.trim().parse().ok()?); + if b < a || b - a > 4096 { + return None; + } + cpus.extend(a..=b); + } + None => cpus.push(part.trim().parse().ok()?), + } + } + cpus.sort_unstable(); + cpus.dedup(); + (!cpus.is_empty()).then_some(cpus) +} + +/// `[0,1,2,3,8]` -> `0-3,8`. +pub fn format_cpulist(cpus: &[usize]) -> String { + let mut out = Vec::new(); + let mut i = 0; + while let Some(&start) = cpus.get(i) { + let mut end = start; + while cpus.get(i + 1) == Some(&(end + 1)) { + end += 1; + i += 1; + } + out.push(if end == start { + start.to_string() + } else { + format!("{start}-{end}") + }); + i += 1; + } + out.join(",") +} + +/// `98304K` / `32M` -> KiB. +pub fn parse_cache_size(text: &str) -> Option { + let text = text.trim(); + let (digits, factor) = if let Some(d) = text.strip_suffix('K') { + (d, 1) + } else if let Some(d) = text.strip_suffix('M') { + (d, 1024) + } else { + (text, 1) + }; + digits.parse::().ok()?.checked_mul(factor) +} + +/// The CPUs that share one last-level cache, and its size. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CacheGroup { + pub cpus: Vec, + pub size_kib: u64, +} + +/// The L3 caches of the machine, one per group of CPUs sharing it, found as +/// `cpu*/cache/index*/` entries of level 3. +pub fn l3_groups(root: &Path) -> Vec { + let Ok(entries) = fs::read_dir(root.join("sys/devices/system/cpu")) else { + return Vec::new(); + }; + let mut groups: BTreeMap, u64> = BTreeMap::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name + .strip_prefix("cpu") + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + { + continue; + } + let Ok(indexes) = fs::read_dir(entry.path().join("cache")) else { + continue; + }; + for index in indexes.flatten() { + let dir = index.path(); + if sys::read_trimmed(dir.join("level")).as_deref() != Some("3") { + continue; + } + let size = sys::read_trimmed(dir.join("size")).and_then(|s| parse_cache_size(&s)); + let shared = + sys::read_trimmed(dir.join("shared_cpu_list")).and_then(|s| parse_cpulist(&s)); + if let (Some(size), Some(shared)) = (size, shared) { + groups.insert(shared, size); + } + } + } + groups + .into_iter() + .map(|(cpus, size_kib)| CacheGroup { cpus, size_kib }) + .collect() +} + +/// The V-Cache complex of an X3D part with two complexes: the one whose L3 +/// is bigger than another's. None when all complexes are alike (a single +/// complex, or ordinary CPUs): there is nothing to choose then. +pub fn vcache_group(groups: &[CacheGroup]) -> Option<&CacheGroup> { + let biggest = groups.iter().max_by_key(|g| g.size_kib)?; + let smallest = groups.iter().min_by_key(|g| g.size_kib)?; + (biggest.size_kib > smallest.size_kib).then_some(biggest) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GpuVendor { + Amd, + Nvidia, + Intel, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Gpu { + /// `card0` + pub card: String, + pub vendor: GpuVendor, + pub device_id: u16, + /// The kernel driver bound to it (`amdgpu`, `i915`, `xe`, `nvidia`, + /// `nouveau`), if one is. + pub driver: Option, + pub slot: Option, + pub boot_vga: bool, +} + +impl Gpu { + /// Turing (GeForce GTX 16 / RTX 20) and newer: the generations NVIDIA's + /// open kernel modules support (the same rule SegLinux Setup uses). + pub fn nvidia_open_capable(&self) -> bool { + self.vendor == GpuVendor::Nvidia && self.device_id >= 0x1e00 + } +} + +fn parse_hex(text: &str) -> Option { + u16::from_str_radix(text.trim().trim_start_matches("0x"), 16).ok() +} + +/// `DRIVER=` and `PCI_SLOT_NAME=` from a device's uevent file. +pub fn parse_uevent(text: &str) -> (Option, Option) { + let get = |key: &str| { + text.lines() + .find_map(|l| l.strip_prefix(key)?.strip_prefix('=')) + .map(str::to_string) + }; + (get("DRIVER"), get("PCI_SLOT_NAME")) +} + +/// The graphics cards of /sys/class/drm (`card0`, `card1`; not their +/// connectors, `card0-HDMI-A-1`). +pub fn gpus(root: &Path) -> Vec { + let Ok(entries) = fs::read_dir(root.join("sys/class/drm")) else { + return Vec::new(); + }; + let mut found: Vec = entries + .flatten() + .filter_map(|e| { + let card = e.file_name().into_string().ok()?; + let number = card.strip_prefix("card")?; + if number.is_empty() || !number.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let dev = e.path().join("device"); + let vendor = match parse_hex(&sys::read_trimmed(dev.join("vendor"))?)? { + 0x1002 => GpuVendor::Amd, + 0x10de => GpuVendor::Nvidia, + 0x8086 => GpuVendor::Intel, + _ => GpuVendor::Other, + }; + let (driver, slot) = + parse_uevent(&sys::read_trimmed(dev.join("uevent")).unwrap_or_default()); + Some(Gpu { + card, + vendor, + device_id: parse_hex(&sys::read_trimmed(dev.join("device"))?)?, + driver, + slot, + boot_vga: sys::read_trimmed(dev.join("boot_vga")).as_deref() == Some("1"), + }) + }) + .collect(); + found.sort_by(|a, b| a.card.cmp(&b.card)); + found +} + +/// The device name `lspci -s SLOT -mm` gives: `1002 "Advanced Micro +/// Devices, Inc. [AMD/ATI]" "Navi 31 [Radeon RX 7900 XT/7900 XTX]"`. +pub fn parse_lspci_name(output: &str) -> Option { + // Machine format: slot, then quoted fields: class, vendor, device, ... + let mut fields = Vec::new(); + let mut rest = output.lines().next()?; + while let Some(start) = rest.find('"') { + let after = rest.get(start + 1..)?; + let end = after.find('"')?; + fields.push(after.get(..end)?.to_string()); + rest = after.get(end + 1..)?; + } + let vendor = fields.get(1)?; + let device = fields.get(2)?; + Some(format!("{vendor} {device}")) +} + +pub fn lspci_name(slot: &str) -> Option { + sys::which("lspci")?; + let out = sys::run("lspci", &["-s", slot, "-mm"]).ok()?; + out.ok.then(|| parse_lspci_name(&out.stdout)).flatten() +} + +/// What each GPU is called, for messages: lspci's name when it can be had. +pub fn describe(gpu: &Gpu) -> String { + let named = gpu.slot.as_deref().and_then(lspci_name); + named.unwrap_or_else(|| { + let vendor = match gpu.vendor { + GpuVendor::Amd => "AMD", + GpuVendor::Nvidia => "NVIDIA", + GpuVendor::Intel => "Intel", + GpuVendor::Other => "unknown vendor", + }; + format!("{vendor} GPU {:04x}", gpu.device_id) + }) +} + +/// `/usr/lib/modules//pkgbase` of the running kernel: `linux-zen`. +pub fn kernel_package(root: &Path) -> Option { + let release = sys::read_trimmed(root.join("proc/sys/kernel/osrelease"))?; + let base = sys::read_trimmed(root.join(format!("usr/lib/modules/{release}/pkgbase")))?; + (!base.is_empty() + && base + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.+".contains(c))) + .then_some(base) +} + +/// `7.2.11-zen1-1-zen` -> (7, 2). +pub fn kernel_version(release: &str) -> Option<(u32, u32)> { + let mut parts = release.trim().split(|c: char| !c.is_ascii_digit()); + Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?)) +} + +/// The cpufreq policy directories (one per CPU or group) below `root`. +pub fn cpufreq_dirs(root: &Path) -> Vec { + let Ok(entries) = fs::read_dir(root.join("sys/devices/system/cpu")) else { + return Vec::new(); + }; + let mut dirs: Vec<(usize, PathBuf)> = entries + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let n: usize = name.strip_prefix("cpu")?.parse().ok()?; + let dir = e.path().join("cpufreq"); + dir.is_dir().then_some((n, dir)) + }) + .collect(); + dirs.sort(); + dirs.into_iter().map(|(_, d)| d).collect() +} + +#[cfg(test)] +pub fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cpu_lists() { + assert_eq!(parse_cpulist("0-3,8"), Some(vec![0, 1, 2, 3, 8])); + assert_eq!( + parse_cpulist("0-7,16-23\n"), + Some((0..8).chain(16..24).collect()) + ); + assert_eq!(parse_cpulist("5"), Some(vec![5])); + assert_eq!(parse_cpulist("3,1,1"), Some(vec![1, 3])); + for bad in ["", "a", "3-1", "1-", "0-99999", "1,,2"] { + assert_eq!(parse_cpulist(bad), None, "{bad}"); + } + assert_eq!(format_cpulist(&[0, 1, 2, 3, 8]), "0-3,8"); + assert_eq!(format_cpulist(&[4]), "4"); + assert_eq!(format_cpulist(&[]), ""); + let all: Vec = (0..8).chain(16..24).collect(); + assert_eq!(format_cpulist(&all), "0-7,16-23"); + } + + #[test] + fn cache_sizes() { + assert_eq!(parse_cache_size("98304K"), Some(98304)); + assert_eq!(parse_cache_size("32M"), Some(32768)); + assert_eq!(parse_cache_size("512"), Some(512)); + assert_eq!(parse_cache_size("lots"), None); + } + + #[test] + fn cpuinfo() { + let c = cpu(&fixture("ryzen-7950x3d")); + assert_eq!(c.vendor, CpuVendor::Amd); + assert_eq!(c.model, "AMD Ryzen 9 7950X3D 16-Core Processor"); + assert_eq!(cpu(&fixture("intel-12700")).vendor, CpuVendor::Intel); + assert_eq!(cpu(&fixture("vm")).model, "QEMU Virtual CPU version 2.5+"); + assert_eq!(parse_cpuinfo("").vendor, CpuVendor::Other); + } + + #[test] + fn a_dual_ccd_x3d_has_one_vcache_complex() { + let groups = l3_groups(&fixture("ryzen-7950x3d")); + assert_eq!(groups.len(), 2); + let v = vcache_group(&groups).expect("a V-Cache complex"); + assert_eq!(v.size_kib, 98304); + assert_eq!(format_cpulist(&v.cpus), "0-7,16-23"); + } + + #[test] + fn the_cache_decides_not_the_complex_number() { + let groups = l3_groups(&fixture("ryzen-second-ccd-vcache")); + let v = vcache_group(&groups).expect("a V-Cache complex"); + assert_eq!(format_cpulist(&v.cpus), "8-15,24-31"); + } + + #[test] + fn nothing_to_pin_when_the_complexes_are_alike() { + // One complex, all V-Cache (7800X3D). + let groups = l3_groups(&fixture("ryzen-7800x3d")); + assert_eq!(groups.len(), 1); + assert_eq!(vcache_group(&groups), None); + // Two ordinary complexes. + assert_eq!(vcache_group(&l3_groups(&fixture("ryzen-5950x"))), None); + assert_eq!(vcache_group(&l3_groups(&fixture("intel-12700"))), None); + // A virtual machine tells nothing. + assert!(l3_groups(&fixture("vm")).is_empty()); + assert_eq!(vcache_group(&[]), None); + } + + #[test] + fn the_gpu_of_an_amd_desktop() { + let g = gpus(&fixture("gpu-amd")); + assert_eq!(g.len(), 1, "connectors are not cards: {g:?}"); + assert_eq!(g[0].card, "card0"); + assert_eq!(g[0].vendor, GpuVendor::Amd); + assert_eq!(g[0].device_id, 0x744c); + assert_eq!(g[0].driver.as_deref(), Some("amdgpu")); + assert_eq!(g[0].slot.as_deref(), Some("0000:03:00.0")); + assert!(g[0].boot_vga); + } + + #[test] + fn nvidia_generations() { + let ada = &gpus(&fixture("gpu-nvidia-ada"))[0]; + assert!(ada.nvidia_open_capable()); + assert_eq!(ada.driver.as_deref(), Some("nvidia")); + // The same card on the open-source driver, before the proprietary + // one is installed. + assert_eq!( + gpus(&fixture("gpu-nvidia-ada-nouveau"))[0] + .driver + .as_deref(), + Some("nouveau") + ); + let pascal = &gpus(&fixture("gpu-nvidia-pascal"))[0]; + assert_eq!(pascal.vendor, GpuVendor::Nvidia); + assert!(!pascal.nvidia_open_capable()); + assert!(!gpus(&fixture("gpu-amd"))[0].nvidia_open_capable()); + } + + #[test] + fn a_hybrid_laptop_has_two_cards_in_order() { + let g = gpus(&fixture("gpu-hybrid-laptop")); + assert_eq!(g.len(), 2); + assert_eq!( + (g[0].card.as_str(), g[0].vendor, g[0].boot_vga), + ("card0", GpuVendor::Intel, true) + ); + assert_eq!( + (g[1].card.as_str(), g[1].vendor, g[1].boot_vga), + ("card1", GpuVendor::Nvidia, false) + ); + assert!(g[1].nvidia_open_capable()); + assert_eq!( + gpus(&fixture("gpu-intel-arc"))[0].driver.as_deref(), + Some("i915") + ); + } + + #[test] + fn no_gpu_and_no_drm_directory() { + assert!(gpus(&fixture("gpu-none")).is_empty()); + assert!(gpus(&fixture("vm")).is_empty()); + assert!(gpus(Path::new("/definitely/not/here")).is_empty()); + } + + #[test] + fn uevent_and_lspci_text() { + assert_eq!( + parse_uevent("DRIVER=amdgpu\nPCI_SLOT_NAME=0000:03:00.0\n"), + (Some("amdgpu".into()), Some("0000:03:00.0".into())) + ); + assert_eq!(parse_uevent("PCI_CLASS=30000\n"), (None, None)); + let out = "03:00.0 \"VGA compatible controller\" \"Advanced Micro Devices, Inc. [AMD/ATI]\" \"Navi 31 [Radeon RX 7900 XT/7900 XTX]\" -rc8 \"Sapphire\" \"Nitro\"\n"; + assert_eq!( + parse_lspci_name(out).as_deref(), + Some("Advanced Micro Devices, Inc. [AMD/ATI] Navi 31 [Radeon RX 7900 XT/7900 XTX]") + ); + assert_eq!(parse_lspci_name(""), None); + assert_eq!(parse_lspci_name("03:00.0 \"VGA\""), None); + } + + #[test] + fn describe_falls_back_to_the_vendor() { + let mut g = gpus(&fixture("gpu-amd"))[0].clone(); + g.slot = None; + assert_eq!(describe(&g), "AMD GPU 744c"); + } + + #[test] + fn kernel_names_and_versions() { + assert_eq!(kernel_version("7.2.11-zen1-1-zen"), Some((7, 2))); + assert_eq!(kernel_version("6.14.0-arch1-1\n"), Some((6, 14))); + assert_eq!(kernel_version("garbage"), None); + let t = sys::testutil::TempDir::new(); + t.put("proc/sys/kernel/osrelease", "7.2.11-zen1-1-zen\n"); + t.put("usr/lib/modules/7.2.11-zen1-1-zen/pkgbase", "linux-zen\n"); + assert_eq!(kernel_package(t.path()).as_deref(), Some("linux-zen")); + t.put( + "usr/lib/modules/7.2.11-zen1-1-zen/pkgbase", + "linux zen; rm\n", + ); + assert_eq!(kernel_package(t.path()), None); + } + + #[test] + fn cpufreq_policies_are_listed_in_cpu_order() { + let t = sys::testutil::TempDir::new(); + for n in [10, 2, 0] { + t.put( + &format!("sys/devices/system/cpu/cpu{n}/cpufreq/scaling_governor"), + "powersave\n", + ); + } + t.put("sys/devices/system/cpu/cpu1/online", "1\n"); + t.put("sys/devices/system/cpu/cpufreq/boost", "1\n"); + let dirs = cpufreq_dirs(t.path()); + let names: Vec<_> = dirs + .iter() + .map(|d| { + d.parent() + .and_then(|p| p.file_name()) + .unwrap() + .to_string_lossy() + .into_owned() + }) + .collect(); + assert_eq!(names, ["cpu0", "cpu2", "cpu10"]); + } +} diff --git a/tools/seg/segctl/src/game/mod.rs b/tools/seg/segctl/src/game/mod.rs new file mode 100644 index 0000000..12dc687 --- /dev/null +++ b/tools/seg/segctl/src/game/mod.rs @@ -0,0 +1,194 @@ +//! `seg game`: what a game needs from the system, and the commands around it. + +pub mod apply; +pub mod audio; +pub mod boost; +pub mod check; +pub mod dbus; +pub mod hw; +pub mod proc; +pub mod profile; +pub mod run; +pub mod setup; +pub mod toml; +pub mod unsafe_boost; + +use crate::error::{Error, Result}; +use crate::sys; + +pub const HELP: &str = "\ +Usage: seg game + + run [options] -- COMMAND run a game with the computer set up for it + (Steam launch option: seg game run -- %command%) + check is this computer ready for games? PASS/FAIL with fixes + setup [--yes] the graphics driver packages this computer needs + profiles [FILE] check and list the per-game profiles + apply apply the gaming sysctls and udev rules (seg-game service) + audio on|off|status low-latency PipeWire settings for games + unsafe-boost on|off|status mitigations=off: faster, less safe (read the warning) + +`seg game --help` tells more. +"; + +/// Dispatch `seg game ...`. Returns the exit status. +pub fn run(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let command = match parser.next()? { + None => { + out!("{HELP}"); + return Ok(0); + } + Some(Short('h') | Long("help")) => { + out!("{HELP}"); + return Ok(0); + } + Some(Value(v)) => v.string()?, + Some(other) => return Err(other.unexpected().into()), + }; + match command.as_str() { + "run" => run::run(run::parse_args(parser)?), + "check" => { + match parser.next()? { + None => {} + Some(lexopt::Arg::Short('h') | lexopt::Arg::Long("help")) => { + out!("{}", check::HELP); + return Ok(0); + } + Some(other) => return Err(other.unexpected().into()), + } + check::run() + } + "setup" => setup::run(setup::parse_args(parser)?), + "apply" => { + match parser.next()? { + None => {} + Some(lexopt::Arg::Short('h') | lexopt::Arg::Long("help")) => { + out!("{}", apply::HELP); + return Ok(0); + } + Some(other) => return Err(other.unexpected().into()), + } + apply::run() + } + "audio" => audio::run(audio::parse_args(parser)?), + "unsafe-boost" => unsafe_boost::run(unsafe_boost::parse_args(parser)?), + "boost" => boost_command(parser), + "profiles" => profiles_command(parser), + other => Err(Error::Usage(format!("unknown game command `{other}`"))), + } +} + +/// The root helper `seg game run` calls through sudo: +/// `boost on PID [GOVERNOR]`, `boost off PID`, `boost status`. Nothing the +/// caller says reaches a file name; the governor must be one the CPU lists. +fn boost_command(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut words: Vec = Vec::new(); + while let Some(arg) = parser.next()? { + match arg { + Value(v) => words.push(v.string()?), + Short('h') | Long("help") => { + out!( + "Usage: seg game boost on PID [GOVERNOR] | off PID | status\n\nThe root helper behind `seg game run`; use that instead.\n" + ); + return Ok(0); + } + other => return Err(other.unexpected().into()), + } + } + let pid = |w: Option<&String>| -> Result { + w.and_then(|s| s.parse::().ok()) + .filter(|p| *p > 1) + .ok_or_else(|| Error::Usage("a process id is needed".into())) + }; + let valid_governor = |g: &str| { + !g.is_empty() + && g.len() <= 32 + && g.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + }; + let words: Vec<&str> = words.iter().map(String::as_str).collect(); + sys::require_root("`seg game boost`")?; + let boost = boost::Boost::new("/"); + let report = match words.as_slice() { + ["on", pid_word] => boost.on(pid(Some(&pid_word.to_string()))?, "performance")?, + ["on", pid_word, governor] if valid_governor(governor) => { + boost.on(pid(Some(&pid_word.to_string()))?, governor)? + } + ["off", pid_word] => boost.off(pid(Some(&pid_word.to_string()))?)?, + ["status"] => { + out!("{}", boost.status()); + return Ok(0); + } + _ => { + return Err(Error::Usage( + "seg game boost on PID [GOVERNOR] | off PID | status".into(), + )); + } + }; + for line in &report.lines { + outln!("{line}"); + } + for w in &report.warnings { + sys::emit_err(format_args!("seg: {w}")); + } + Ok(0) +} + +fn profiles_command(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut file: Option = None; + while let Some(arg) = parser.next()? { + match arg { + Value(v) if file.is_none() => file = Some(v.into()), + Short('h') | Long("help") => { + out!( + "Usage: seg game profiles [FILE]\n\nChecks ~/.config/seg/games.toml (or FILE) and lists what it says.\n" + ); + return Ok(0); + } + other => return Err(other.unexpected().into()), + } + } + let path = file + .or_else(profile::default_path) + .ok_or_else(|| Error::other("cannot tell where the profiles are (no HOME)"))?; + let profiles = profile::load(&path)?; + if profiles.is_empty() { + outln!( + "{}: no profiles (it is read when a game starts)", + path.display() + ); + return Ok(0); + } + outln!( + "{}: {} profile{}", + path.display(), + profiles.len(), + if profiles.len() == 1 { "" } else { "s" } + ); + for p in &profiles { + let governor = match &p.governor { + profile::Governor::Performance => "performance".to_string(), + profile::Governor::Keep => "keep".to_string(), + profile::Governor::Named(n) => n.clone(), + }; + let pin = match &p.pin { + profile::Pin::Off => "off".to_string(), + profile::Pin::VCache => "vcache".to_string(), + profile::Pin::Cpus(c) => hw::format_cpulist(c), + }; + outln!( + " {:<24} governor {governor}, pin {pin}, mangohud {}, gamescope {}", + p.matcher, + if p.mangohud { "on" } else { "off" }, + if p.gamescope.is_empty() { + "off".to_string() + } else { + p.gamescope.join(" ") + } + ); + } + Ok(0) +} diff --git a/tools/seg/segctl/src/game/proc.rs b/tools/seg/segctl/src/game/proc.rs new file mode 100644 index 0000000..8635ae6 --- /dev/null +++ b/tools/seg/segctl/src/game/proc.rs @@ -0,0 +1,280 @@ +//! The few system calls std does not wrap, in one place: waiting for the +//! game while signals arrive, priorities, CPU pinning and file locks. Every +//! `unsafe` block of seg is in this file. + +#![allow(unsafe_code)] + +use std::fs::File; +use std::io; +use std::os::fd::AsRawFd; +use std::sync::atomic::{AtomicI32, Ordering}; +use std::time::{Duration, Instant}; + +/// The game's process id, for the signal handler to forward to. +static CHILD: AtomicI32 = AtomicI32::new(0); +/// The first signal that asked seg to stop (0: none yet). +static SIGNALLED: AtomicI32 = AtomicI32::new(0); + +extern "C" fn on_signal(sig: libc::c_int) { + // Only async-signal-safe work: atomics and kill(2). + let _ = SIGNALLED.compare_exchange(0, sig, Ordering::SeqCst, Ordering::SeqCst); + let child = CHILD.load(Ordering::SeqCst); + // Ctrl+C already reached the game through the terminal; the others + // were sent to seg alone. + if child > 0 && sig != libc::SIGINT { + // SAFETY: kill is async-signal-safe and takes plain integers. + unsafe { libc::kill(child, sig) }; + } +} + +/// Tell the signal handler where to forward signals. +pub fn set_child(pid: u32) { + CHILD.store(pid as i32, Ordering::SeqCst); +} + +/// Make INT, TERM, HUP and QUIT stop the wait (and reach the game) instead +/// of killing seg before it has put the system back. +pub fn install_signal_handlers() -> io::Result<()> { + for sig in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGQUIT] { + // SAFETY: a zeroed sigaction is valid; the handler is an + // `extern "C" fn(c_int)` that only does async-signal-safe work. + let rc = unsafe { + let mut action: libc::sigaction = std::mem::zeroed(); + action.sa_sigaction = on_signal as extern "C" fn(libc::c_int) as usize; + // No SA_RESTART: waitpid must return so the loop sees the flag. + action.sa_flags = 0; + libc::sigemptyset(&mut action.sa_mask); + libc::sigaction(sig, &action, std::ptr::null_mut()) + }; + if rc != 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(()) +} + +/// Start `command` with the given process settings applied between fork and +/// exec, so the game and everything it starts inherit them. Failures to +/// apply a setting (no right to a negative nice value, say) are ignored: +/// the game should start slower rather than not at all. +pub fn spawn_prepared( + command: &mut std::process::Command, + nice: Option, + io_priority: bool, + cpus: Option>, +) -> io::Result { + use std::os::unix::process::CommandExt; + // SAFETY: the closure runs in the forked child before exec and only makes + // system calls (setpriority, ioprio_set, sched_setaffinity); it does not + // allocate or take locks. + unsafe { + command.pre_exec(move || { + if let Some(n) = nice { + let _ = set_nice(n); + } + if io_priority { + let _ = set_ioprio_best_effort_top(); + } + if let Some(cpus) = &cpus { + let _ = set_affinity(cpus); + } + Ok(()) + }); + } + command.spawn() +} + +pub fn pending_signal() -> Option { + match SIGNALLED.load(Ordering::SeqCst) { + 0 => None, + sig => Some(sig), + } +} + +pub fn send_signal(pid: u32, sig: i32) { + // SAFETY: kill with a process id and a signal number. + unsafe { libc::kill(pid as i32, sig) }; +} + +/// The exit code of a wait status, as a shell reports it: the code, or 128 +/// plus the signal that ended the process. +pub fn exit_code(status: i32) -> i32 { + if libc::WIFEXITED(status) { + libc::WEXITSTATUS(status) + } else if libc::WIFSIGNALED(status) { + 128 + libc::WTERMSIG(status) + } else { + 1 + } +} + +/// Wait for `pid` to end while signals may arrive. After one does, the +/// game gets 5 seconds to leave, then SIGTERM, then at 15 SIGKILL, so seg is +/// never stuck behind a game that ignores it. Returns the exit code. +pub fn wait_for(pid: u32) -> io::Result { + let mut stopping: Option = None; + let mut terminated = false; + loop { + if stopping.is_none() && pending_signal().is_some() { + stopping = Some(Instant::now()); + } + let flags = if stopping.is_some() { libc::WNOHANG } else { 0 }; + let mut status = 0; + // SAFETY: waitpid writes one int through a valid pointer. + let rc = unsafe { libc::waitpid(pid as i32, &mut status, flags) }; + if rc == pid as i32 { + return Ok(exit_code(status)); + } + if rc == -1 { + let e = io::Error::last_os_error(); + match e.raw_os_error() { + Some(libc::EINTR) => continue, + // Somebody else reaped it: nothing left to wait for. + Some(libc::ECHILD) => return Ok(0), + _ => return Err(e), + } + } + if let Some(since) = stopping { + let waited = since.elapsed(); + if waited > Duration::from_secs(5) && !terminated { + send_signal(pid, libc::SIGTERM); + terminated = true; + } + if waited > Duration::from_secs(15) { + send_signal(pid, libc::SIGKILL); + } + std::thread::sleep(Duration::from_millis(100)); + } + } +} + +/// Ask for the nice value `nice` for this process (and so for what it starts). +/// Negative values need the `nice` limit pam_limits gives the gamemode group. +pub fn set_nice(nice: i32) -> io::Result<()> { + // SAFETY: setpriority takes plain integers. + let rc = unsafe { libc::setpriority(libc::PRIO_PROCESS, 0, nice) }; + if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +/// The highest priority within the best-effort I/O class. +pub fn set_ioprio_best_effort_top() -> io::Result<()> { + const IOPRIO_WHO_PROCESS: libc::c_long = 1; + const IOPRIO_CLASS_BE: libc::c_long = 2; + const IOPRIO_CLASS_SHIFT: libc::c_long = 13; + // SAFETY: ioprio_set(2) takes three plain integers. + let rc = unsafe { + libc::syscall( + libc::SYS_ioprio_set, + IOPRIO_WHO_PROCESS, + 0 as libc::c_long, + IOPRIO_CLASS_BE << IOPRIO_CLASS_SHIFT, + ) + }; + if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +/// Restrict this process (and what it starts) to `cpus`. +pub fn set_affinity(cpus: &[usize]) -> io::Result<()> { + // SAFETY: cpu_set_t is plain data; CPU_SET only sets bits inside it and + // is skipped for numbers it cannot hold. + unsafe { + let mut set: libc::cpu_set_t = std::mem::zeroed(); + libc::CPU_ZERO(&mut set); + let capacity = 8 * std::mem::size_of::(); + let mut any = false; + for &cpu in cpus { + if cpu < capacity { + libc::CPU_SET(cpu, &mut set); + any = true; + } + } + if !any { + return Err(io::Error::from(io::ErrorKind::InvalidInput)); + } + if libc::sched_setaffinity(0, std::mem::size_of::(), &set) == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } +} + +/// An exclusive lock on `file`, released when it is closed. +pub fn lock_exclusive(file: &File) -> io::Result<()> { + loop { + // SAFETY: flock on a file descriptor that stays open for the call. + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; + if rc == 0 { + return Ok(()); + } + let e = io::Error::last_os_error(); + if e.raw_os_error() != Some(libc::EINTR) { + return Err(e); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + #[test] + fn wait_status_becomes_a_shell_exit_code() { + let exited = |code: i32| code << 8; + assert_eq!(exit_code(exited(0)), 0); + assert_eq!(exit_code(exited(3)), 3); + // Killed by SIGKILL (9). + assert_eq!(exit_code(9), 137); + // Killed by SIGTERM (15). + assert_eq!(exit_code(15), 143); + } + + // wait_for reaps the children itself (waitpid), which clippy cannot see. + #[test] + #[allow(clippy::zombie_processes)] + fn waiting_returns_the_childs_exit_code() { + let child = Command::new("sh").args(["-c", "exit 7"]).spawn().unwrap(); + assert_eq!(wait_for(child.id()).unwrap(), 7); + let child = Command::new("sh") + .args(["-c", "kill -9 $$"]) + .spawn() + .unwrap(); + assert_eq!(wait_for(child.id()).unwrap(), 137); + } + + #[test] + fn a_prepared_child_gets_its_nice_value_and_cpus() { + let mut cmd = std::process::Command::new("sh"); + // nice can always be raised; the child reports its own values. + cmd.args(["-c", "ps -o ni= -p $$ | tr -d ' ' >&2; exit 3"]) + .stderr(std::process::Stdio::piped()); + let child = spawn_prepared(&mut cmd, Some(5), true, Some(vec![0])).unwrap(); + let out = child.wait_with_output().unwrap(); + assert_eq!(out.status.code(), Some(3)); + assert_eq!(String::from_utf8_lossy(&out.stderr).trim(), "5"); + } + + #[test] + fn affinity_needs_at_least_one_cpu() { + assert!(set_affinity(&[]).is_err()); + assert!(set_affinity(&[1_000_000]).is_err()); + } + + #[test] + fn locks_can_be_taken_twice_in_one_process() { + // flock is per open file description: a second open blocks, but the + // same description may lock again. + let f = File::open("/proc/self/status").unwrap(); + lock_exclusive(&f).unwrap(); + lock_exclusive(&f).unwrap(); + } +} diff --git a/tools/seg/segctl/src/game/profile.rs b/tools/seg/segctl/src/game/profile.rs new file mode 100644 index 0000000..1dfca61 --- /dev/null +++ b/tools/seg/segctl/src/game/profile.rs @@ -0,0 +1,411 @@ +//! Per-game profiles: ~/.config/seg/games.toml, one `[[game]]` per program, +//! matched by the name of the executable. +//! +//! ```toml +//! [[game]] +//! match = "eldenring.exe" # glob; compared with the file names in the command +//! governor = "performance" # or "keep" to leave the CPU alone, or another governor +//! pin = "vcache" # "vcache", "off" or a CPU list such as "0-7,16-23" +//! mangohud = true +//! gamescope = ["-W", "2560", "-H", "1440", "-r", "165", "--adaptive-sync"] +//! ``` + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::hw::parse_cpulist; +use super::toml::{self, Table, Value}; +use crate::error::{Error, Result}; + +/// What the CPU governor does while the game runs. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Governor { + /// `performance`. + #[default] + Performance, + /// Leave it as it is: no boost for this game. + Keep, + Named(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Pin { + #[default] + Off, + /// The complex with the biggest L3 cache, on an X3D part with two. + VCache, + Cpus(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Profile { + pub matcher: String, + pub governor: Governor, + pub pin: Pin, + pub mangohud: bool, + /// A MangoHud config file; `preset` means the one SegLinux ships. + pub mangohud_config: Option, + pub gamescope: Vec, + pub nice: Option, + /// Line of the `[[game]]` header, for messages. + pub line: usize, +} + +pub const KNOWN_KEYS: [&str; 7] = [ + "match", + "governor", + "pin", + "mangohud", + "mangohud_config", + "gamescope", + "nice", +]; + +pub fn parse_pin(text: &str) -> std::result::Result { + match text.trim() { + "off" | "none" | "" => Ok(Pin::Off), + "vcache" | "v-cache" | "auto" => Ok(Pin::VCache), + list => parse_cpulist(list).map(Pin::Cpus).ok_or_else(|| { + format!("`{list}` is not `vcache`, `off` or a CPU list such as 0-7,16-23") + }), + } +} + +fn valid_governor(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') +} + +fn one(table: &Table) -> std::result::Result { + for (key, _, line) in &table.entries { + if !KNOWN_KEYS.contains(&key.as_str()) { + return Err(format!( + "line {line}: unknown setting `{key}` (known: {})", + KNOWN_KEYS.join(", ") + )); + } + } + let text = |key: &str| -> std::result::Result, String> { + match table.get(key) { + None => Ok(None), + Some(Value::Str(s)) => Ok(Some(s.clone())), + Some(_) => Err(format!( + "`{key}` must be a string (table at line {})", + table.line + )), + } + }; + let matcher = text("match")? + .filter(|m| !m.trim().is_empty()) + .ok_or_else(|| format!("the [[game]] at line {} has no `match`", table.line))?; + let governor = match text("governor")?.as_deref() { + None | Some("performance") => Governor::Performance, + Some("keep") => Governor::Keep, + Some(name) if valid_governor(name) => Governor::Named(name.to_string()), + Some(other) => { + return Err(format!( + "`{other}` is not a governor (table at line {})", + table.line + )); + } + }; + let pin = match text("pin")? { + None => Pin::Off, + Some(p) => parse_pin(&p).map_err(|e| format!("pin: {e} (table at line {})", table.line))?, + }; + let mangohud = match table.get("mangohud") { + None => false, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(format!( + "`mangohud` must be true or false (table at line {})", + table.line + )); + } + }; + let gamescope = match table.get("gamescope") { + None => Vec::new(), + Some(Value::Array(items)) => items + .iter() + .map(|v| match v { + Value::Str(s) => Ok(s.clone()), + Value::Int(n) => Ok(n.to_string()), + _ => Err(format!( + "`gamescope` must list strings (table at line {})", + table.line + )), + }) + .collect::>()?, + Some(_) => { + return Err(format!( + "`gamescope` must be a list of strings (table at line {})", + table.line + )); + } + }; + let nice = match table.get("nice") { + None => None, + Some(Value::Int(n)) if (-20..=19).contains(n) => Some(*n as i32), + Some(_) => { + return Err(format!( + "`nice` must be a number from -20 to 19 (table at line {})", + table.line + )); + } + }; + Ok(Profile { + matcher, + governor, + pin, + mangohud, + mangohud_config: text("mangohud_config")?, + gamescope, + nice, + line: table.line, + }) +} + +pub fn parse(text: &str) -> std::result::Result, String> { + let tables = toml::parse(text).map_err(|e| e.to_string())?; + tables + .iter() + .map(|t| { + if t.name == "game" { + one(t) + } else { + Err(format!( + "line {}: unknown table [[{}]] (only [[game]])", + t.line, t.name + )) + } + }) + .collect() +} + +/// Where the profiles are: `$XDG_CONFIG_HOME/seg/games.toml`, else +/// `~/.config/seg/games.toml` of the user who ran `sudo`, if any. +pub fn default_path() -> Option { + let base = match std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) { + Some(dir) => PathBuf::from(dir), + None => crate::sys::invoking_home()?.join(".config"), + }; + Some(base.join("seg/games.toml")) +} + +/// The profiles of a file; a missing file has none. +pub fn load(path: &Path) -> Result> { + let text = match fs::read_to_string(path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(Error::io(format!("reading {}", path.display()), e)), + }; + parse(&text).map_err(|e| Error::other(format!("{}: {e}", path.display()))) +} + +/// `*` and `?` globbing, without regard to case (Windows programs are +/// written both ways). +pub fn glob(pattern: &str, name: &str) -> bool { + let p: Vec = pattern.to_lowercase().chars().collect(); + let n: Vec = name.to_lowercase().chars().collect(); + let (mut pi, mut ni) = (0, 0); + let mut star: Option<(usize, usize)> = None; + while ni < n.len() { + match p.get(pi) { + Some('*') => { + star = Some((pi, ni)); + pi += 1; + } + Some(&c) if c == '?' || Some(&c) == n.get(ni) => { + pi += 1; + ni += 1; + } + _ => match star { + Some((sp, sn)) => { + pi = sp + 1; + ni = sn + 1; + star = Some((sp, sn + 1)); + } + None => return false, + }, + } + } + while p.get(pi) == Some(&'*') { + pi += 1; + } + pi == p.len() +} + +/// The last component of a path, or of a Windows path. +pub fn file_name(arg: &str) -> &str { + arg.rsplit(['/', '\\']).next().unwrap_or(arg) +} + +/// The first profile whose `match` fits the name of any program in the +/// command: `seg game run -- %command%` becomes a Proton command line +/// (`.../proton waitforexitandrun /games/Game/game.exe`), so every argument +/// counts, not just the first. +pub fn find<'a>(profiles: &'a [Profile], command: &[String]) -> Option<&'a Profile> { + profiles.iter().find(|p| { + command + .iter() + .any(|arg| glob(&p.matcher, file_name(arg)) || glob(&p.matcher, arg)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cmd(args: &[&str]) -> Vec { + args.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn a_full_profile() { + let p = parse( + r#" +[[game]] +match = "eldenring.exe" +governor = "performance" +pin = "vcache" +mangohud = true +mangohud_config = "preset" +gamescope = ["-W", "2560", "-H", 1440, "--adaptive-sync"] +nice = -8 +"#, + ) + .unwrap(); + assert_eq!(p.len(), 1); + assert_eq!(p[0].matcher, "eldenring.exe"); + assert_eq!(p[0].governor, Governor::Performance); + assert_eq!(p[0].pin, Pin::VCache); + assert!(p[0].mangohud); + assert_eq!(p[0].mangohud_config.as_deref(), Some("preset")); + assert_eq!( + p[0].gamescope, + ["-W", "2560", "-H", "1440", "--adaptive-sync"] + ); + assert_eq!(p[0].nice, Some(-8)); + assert_eq!(p[0].line, 2); + } + + #[test] + fn defaults_are_a_plain_boost() { + let p = parse("[[game]]\nmatch = \"x\"\n").unwrap(); + assert_eq!(p[0].governor, Governor::Performance); + assert_eq!(p[0].pin, Pin::Off); + assert!(!p[0].mangohud); + assert!(p[0].gamescope.is_empty()); + assert_eq!(p[0].nice, None); + } + + #[test] + fn governors_and_pins() { + let p = parse("[[game]]\nmatch=\"a\"\ngovernor=\"keep\"\npin=\"0-7,16-23\"\n[[game]]\nmatch=\"b\"\ngovernor=\"schedutil\"\npin=\"off\"\n").unwrap(); + assert_eq!(p[0].governor, Governor::Keep); + assert_eq!(p[0].pin, Pin::Cpus((0..8).chain(16..24).collect())); + assert_eq!(p[1].governor, Governor::Named("schedutil".into())); + assert_eq!(p[1].pin, Pin::Off); + assert_eq!(parse_pin("auto"), Ok(Pin::VCache)); + assert!(parse_pin("3-1").is_err()); + } + + #[test] + fn mistakes_are_reported_not_ignored() { + let cases = [ + ("[[game]]\ngovernor = \"keep\"\n", "no `match`"), + ("[[game]]\nmatch = \"\"\n", "no `match`"), + ( + "[[game]]\nmatch = \"x\"\ngovenor = \"keep\"\n", + "unknown setting `govenor`", + ), + ( + "[[game]]\nmatch = \"x\"\nmangohud = \"yes\"\n", + "must be true or false", + ), + ("[[game]]\nmatch = \"x\"\nnice = 40\n", "from -20 to 19"), + ( + "[[game]]\nmatch = \"x\"\ngovernor = \"Per formance\"\n", + "is not a governor", + ), + ("[[game]]\nmatch = \"x\"\npin = \"left\"\n", "pin:"), + ( + "[[game]]\nmatch = \"x\"\ngamescope = \"-W 100\"\n", + "list of strings", + ), + ("[[other]]\nmatch = \"x\"\n", "unknown table [[other]]"), + ("[[game]]\nmatch = x\n", "line 2"), + ]; + for (doc, fragment) in cases { + let e = parse(doc).unwrap_err(); + assert!(e.contains(fragment), "{doc:?} -> {e}"); + } + } + + #[test] + fn globs() { + for (pat, name, want) in [ + ("eldenring.exe", "EldenRing.exe", true), + ("*.exe", "game.EXE", true), + ("cs2*", "cs2", true), + ("cs2*", "cs2.sh", true), + ("g?me", "game", true), + ("g?me", "gme", false), + ("*launcher*", "My Launcher v2.exe", true), + ("a*b*c", "aXXbYYc", true), + ("a*b*c", "aXXbYY", false), + ("*", "", true), + ("", "", true), + ("x", "", false), + ] { + assert_eq!(glob(pat, name), want, "{pat} vs {name}"); + } + } + + #[test] + fn a_proton_command_line_matches_its_exe() { + let profiles = + parse("[[game]]\nmatch = \"eldenring.exe\"\n[[game]]\nmatch = \"cs2\"\nnice = 5\n") + .unwrap(); + let proton = cmd(&[ + "/home/u/.steam/root/compatibilitytools.d/GE-Proton/proton", + "waitforexitandrun", + "/games/ELDEN RING/Game/eldenring.exe", + ]); + assert_eq!(find(&profiles, &proton).map(|p| p.line), Some(1)); + // A Windows-style path (an installer script passes one) too. + assert!( + find( + &profiles, + &cmd(&["wine", "C:\\Games\\EldenRing\\eldenring.exe"]) + ) + .is_some() + ); + let native = cmd(&[ + "/home/u/.steam/steamapps/common/CS2/game/bin/linuxsteamrt64/cs2", + "-novid", + ]); + assert_eq!(find(&profiles, &native).and_then(|p| p.nice), Some(5)); + assert!(find(&profiles, &cmd(&["glxgears"])).is_none()); + assert!(find(&[], &native).is_none()); + } + + #[test] + fn the_first_matching_profile_wins() { + let p = + parse("[[game]]\nmatch = \"*.exe\"\nnice = 1\n[[game]]\nmatch = \"a.exe\"\nnice = 2\n") + .unwrap(); + assert_eq!(find(&p, &cmd(&["a.exe"])).and_then(|x| x.nice), Some(1)); + } + + #[test] + fn a_missing_file_has_no_profiles_and_a_broken_one_names_itself() { + let t = crate::sys::testutil::TempDir::new(); + assert_eq!(load(&t.path().join("none.toml")).unwrap(), vec![]); + let broken = t.put("games.toml", "[[game]]\nmatch = oops\n"); + let e = load(&broken).unwrap_err().to_string(); + assert!(e.contains("games.toml: line 2"), "{e}"); + } +} diff --git a/tools/seg/segctl/src/game/run.rs b/tools/seg/segctl/src/game/run.rs new file mode 100644 index 0000000..821cb4c --- /dev/null +++ b/tools/seg/segctl/src/game/run.rs @@ -0,0 +1,607 @@ +//! `seg game run -- COMMAND`: a lightweight GameMode for the length of one +//! game. Steam launch option: `seg game run -- %command%`. +//! +//! While the game runs: CPU governor and energy preference `performance`, +//! an AMD GPU at its high power level (through the root helper, see +//! boost.rs), a better CPU and I/O priority for the game, the screen saver +//! held off, and, if asked, the game on the V-Cache cores of an X3D CPU. +//! When it ends, or seg is interrupted, or seg itself fails, every setting +//! goes back. Nothing is left changed. + +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::boost::Boost; +use super::dbus; +use super::hw; +use super::proc; +use super::profile::{self, Governor, Pin, Profile}; +use crate::error::{Error, Result}; +use crate::sys; + +pub const HELP: &str = "\ +Usage: seg game run [options] -- COMMAND [ARGS...] + +Runs COMMAND with the computer set up for a game, and puts everything back +when it ends (also on Ctrl+C, SIGTERM and a crash of seg). In Steam, set the +game's launch options to: seg game run -- %command% + +While the game runs: + - CPU governor and energy preference: performance; an AMD GPU: high power + level (needs the gamemode group, or root) + - the game's CPU priority is raised a little and its I/O priority is the + best of its class + - the screen saver and idle blanking are held off (session bus) + - with --pin vcache, or pin = \"vcache\" in the game's profile: only the + cores of the V-Cache complex of an X3D CPU with two + +Options: + --no-boost do not change the governor or the GPU + --pin WHERE vcache, off, or a CPU list such as 0-7,16-23 + --nice N CPU priority of the game, -20..19 (default -4) + --profiles FILE game profiles (default ~/.config/seg/games.toml) + --dry-run show what would be done and stop +"; + +const DEFAULT_NICE: i32 = -4; +const SEG: &str = "/usr/bin/seg"; + +#[derive(Debug, Default)] +pub struct Options { + pub no_boost: bool, + pub pin: Option, + pub nice: Option, + pub profiles: Option, + pub dry_run: bool, + pub command: Vec, +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut o = Options::default(); + while let Some(arg) = parser.next()? { + match arg { + Long("no-boost") => o.no_boost = true, + Long("dry-run") => o.dry_run = true, + Long("pin") => { + o.pin = Some(profile::parse_pin(&parser.value()?.string()?).map_err(Error::Usage)?); + } + Long("nice") => { + let n: i32 = parser.value()?.parse()?; + if !(-20..=19).contains(&n) { + return Err(Error::Usage("--nice must be from -20 to 19".into())); + } + o.nice = Some(n); + } + Long("profiles") => o.profiles = Some(parser.value()?.into()), + Short('h') | Long("help") => { + out!("{HELP}"); + std::process::exit(0); + } + // Everything after `--` is the command, whatever it looks like. + Value(first) => { + o.command.push(first.string()?); + o.command + .extend(parser.raw_args()?.map(|a| a.to_string_lossy().into_owned())); + break; + } + other => return Err(other.unexpected().into()), + } + } + if o.command.is_empty() { + return Err(Error::Usage( + "seg game run needs a command: seg game run -- COMMAND".into(), + )); + } + Ok(o) +} + +/// What the machine offers, for planning. +#[derive(Debug, Default, Clone)] +pub struct Machine { + /// CPUs of the V-Cache complex, when there is one to choose. + pub vcache: Option>, + pub mangohud: bool, + pub gamescope: bool, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Plan { + /// The governor for the boost; None: no boost. + pub governor: Option, + pub cpus: Option>, + pub nice: i32, + /// The command to start, with gamescope and MangoHud wrapped around it. + pub argv: Vec, + /// Environment the game gets unless it is already set. + pub env: Vec<(String, String)>, + pub notes: Vec, +} + +pub const PRESET: &str = "/usr/share/seg/mangohud-gaming.conf"; + +/// Decide what to do. `is_set(name)` tells whether the user already set an +/// environment variable (theirs wins). +pub fn plan( + o: &Options, + profile: Option<&Profile>, + machine: &Machine, + is_set: &dyn Fn(&str) -> bool, +) -> Plan { + let mut notes = Vec::new(); + let governor = if o.no_boost { + None + } else { + match profile.map(|p| &p.governor) { + None | Some(Governor::Performance) => Some("performance".to_string()), + Some(Governor::Keep) => None, + Some(Governor::Named(n)) => Some(n.clone()), + } + }; + let wanted_pin = o + .pin + .clone() + .or_else(|| profile.map(|p| p.pin.clone())) + .unwrap_or_default(); + let cpus = match wanted_pin { + Pin::Off => None, + Pin::Cpus(list) => Some(list), + Pin::VCache => match &machine.vcache { + Some(cpus) => Some(cpus.clone()), + None => { + notes.push( + "no V-Cache complex to pin to (not an X3D CPU with two); not pinning".into(), + ); + None + } + }, + }; + let mut argv = o.command.clone(); + let mut env: Vec<(String, String)> = Vec::new(); + if let Some(p) = profile { + if p.mangohud { + if machine.mangohud { + argv.insert(0, "mangohud".into()); + let config = match p.mangohud_config.as_deref() { + Some("preset") => Some(PRESET.to_string()), + Some(path) => Some(path.to_string()), + None => None, + }; + if let Some(config) = config + && !is_set("MANGOHUD_CONFIGFILE") + { + env.push(("MANGOHUD_CONFIGFILE".into(), config)); + } + } else { + notes.push("MangoHud is not installed; running without it".into()); + } + } + if !p.gamescope.is_empty() { + if machine.gamescope { + let mut wrapped = vec!["gamescope".to_string()]; + wrapped.extend(p.gamescope.iter().cloned()); + wrapped.push("--".into()); + wrapped.extend(argv); + argv = wrapped; + } else { + notes.push("gamescope is not installed; running without it".into()); + } + } + } + // Shader caches: room for the compiled shaders of many games (the 1 GB + // defaults make games recompile them and stutter). + for (name, value) in [ + ("MESA_SHADER_CACHE_MAX_SIZE", "10G"), + ("__GL_SHADER_DISK_CACHE_SIZE", "10737418240"), + ("__GL_SHADER_DISK_CACHE_SKIP_CLEANUP", "1"), + ] { + if !is_set(name) { + env.push((name.into(), value.into())); + } + } + Plan { + governor, + cpus, + nice: o + .nice + .or(profile.and_then(|p| p.nice)) + .unwrap_or(DEFAULT_NICE), + argv, + env, + notes, + } +} + +/// The root helper, as seen from the process that asks for the boost. +static BOOST_HELD: AtomicBool = AtomicBool::new(false); + +fn as_root() -> bool { + sys::euid().is_ok_and(|u| u == 0) +} + +/// Switch the boost on for this process. Failure is a warning: the game runs. +fn boost_on(governor: &str) { + let pid = std::process::id(); + let failure: Option = if as_root() { + match Boost::new("/").on(pid, governor) { + Ok(report) => report.warnings.into_iter().next(), + Err(e) => Some(e.to_string()), + } + } else { + match sys::run( + "sudo", + &["-n", SEG, "game", "boost", "on", &pid.to_string(), governor], + ) { + Ok(out) if out.ok => None, + Ok(out) => Some(out.stderr.trim().to_string()), + Err(e) => Some(e.to_string()), + } + }; + match failure { + None => BOOST_HELD.store(true, Ordering::SeqCst), + Some(why) => { + // A partial boost is still a boost to give back. + BOOST_HELD.store(true, Ordering::SeqCst); + sys::emit_err(format_args!( + "seg: the performance boost is not (fully) on: {}\nseg: run `seg game check` for the fix", + if why.is_empty() { + "the helper failed" + } else { + &why + } + )); + } + } +} + +/// Put everything back. Safe to call twice, and from the panic hook. +pub fn boost_off() { + if !BOOST_HELD.swap(false, Ordering::SeqCst) { + return; + } + let pid = std::process::id(); + if as_root() { + let _ = Boost::new("/").off(pid); + } else { + let _ = sys::run( + "sudo", + &["-n", SEG, "game", "boost", "off", &pid.to_string()], + ); + } +} + +/// Puts the system back when it goes out of scope. +struct Session { + _idle: Option, +} + +impl Drop for Session { + fn drop(&mut self) { + boost_off(); + } +} + +/// Can `program` be started? A path must be a file you may run; a bare name +/// must be found in PATH. +fn program_exists(program: &str) -> bool { + use std::os::unix::fs::PermissionsExt; + if program.contains('/') { + std::fs::metadata(program).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + } else { + sys::which(program).is_some() + } +} + +fn describe(plan: &Plan, profile: Option<&Profile>) -> String { + let mut text = String::new(); + match profile { + Some(p) => text.push_str(&format!( + "profile: match \"{}\" (line {})\n", + p.matcher, p.line + )), + None => text.push_str("profile: none matched\n"), + } + text.push_str(&format!( + "boost: {}\n", + plan.governor + .as_deref() + .map_or("off".to_string(), |g| format!( + "governor {g}, energy preference and GPU level" + )) + )); + text.push_str(&format!( + "pin: {}\n", + plan.cpus + .as_deref() + .map_or("no".to_string(), hw::format_cpulist) + )); + text.push_str(&format!("nice: {}\n", plan.nice)); + for (k, v) in &plan.env { + text.push_str(&format!("env: {k}={v}\n")); + } + text.push_str(&format!("command: {}\n", plan.argv.join(" "))); + for n in &plan.notes { + text.push_str(&format!("note: {n}\n")); + } + text +} + +pub fn run(o: Options) -> Result { + let profiles = match o.profiles.clone().or_else(profile::default_path) { + Some(path) => profile::load(&path).unwrap_or_else(|e| { + // A typo in games.toml must not keep anyone from playing. + sys::emit_err(format_args!("seg: {e}\nseg: playing without profiles")); + Vec::new() + }), + None => Vec::new(), + }; + let found = profile::find(&profiles, &o.command); + let machine = Machine { + vcache: hw::vcache_group(&hw::l3_groups(std::path::Path::new("/"))).map(|g| g.cpus.clone()), + mangohud: sys::which("mangohud").is_some(), + gamescope: sys::which("gamescope").is_some(), + }; + let plan = plan(&o, found, &machine, &|name| { + std::env::var_os(name).is_some() + }); + if o.dry_run { + out!("{}", describe(&plan, found)); + return Ok(0); + } + for n in &plan.notes { + sys::emit_err(format_args!("seg: {n}")); + } + // A program that is not there fails before anything is changed, with the + // status a shell gives. + if let Some(program) = plan.argv.first() + && !program_exists(program) + { + sys::emit_err(format_args!("seg: `{program}`: command not found")); + return Ok(127); + } + + // From here on, everything changed is changed back: when the game ends, + // on a signal (the wait returns) and on a crash of seg (the hook). + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + boost_off(); + previous(info); + })); + if let Err(e) = proc::install_signal_handlers() { + sys::emit_err(format_args!( + "seg: cannot catch signals ({e}); Ctrl+C may leave the boost on" + )); + } + let session = Session { + _idle: dbus::inhibit_screensaver("seg", "playing a game").ok(), + }; + if let Some(governor) = &plan.governor { + boost_on(governor); + } + + let Some((program, args)) = plan.argv.split_first() else { + return Err(Error::Usage("seg game run needs a command".into())); + }; + let mut command = Command::new(program); + command.args(args); + for (k, v) in &plan.env { + command.env(k, v); + } + let child = proc::spawn_prepared(&mut command, Some(plan.nice), true, plan.cpus.clone()); + let code = match child { + Ok(child) => { + proc::set_child(child.id()); + proc::wait_for(child.id()).unwrap_or(1) + } + Err(e) => { + drop(session); + return Err(Error::Command { + program: program.clone(), + detail: format!("cannot start it: {e}"), + }); + } + }; + drop(session); + Ok((code & 0xff) as u8) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::profile::parse; + + fn opts(cmd: &[&str]) -> Options { + Options { + command: cmd.iter().map(|s| s.to_string()).collect(), + ..Options::default() + } + } + + fn machine() -> Machine { + Machine { + vcache: Some(vec![0, 1, 2, 3]), + mangohud: true, + gamescope: true, + } + } + + fn none_set(_: &str) -> bool { + false + } + + #[test] + fn a_plain_game_gets_the_boost_and_shader_caches() { + let p = plan( + &opts(&["./game", "-fullscreen"]), + None, + &machine(), + &none_set, + ); + assert_eq!(p.governor.as_deref(), Some("performance")); + assert_eq!(p.cpus, None); + assert_eq!(p.nice, -4); + assert_eq!(p.argv, ["./game", "-fullscreen"]); + let env: Vec<_> = p.env.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!( + env, + [ + "MESA_SHADER_CACHE_MAX_SIZE", + "__GL_SHADER_DISK_CACHE_SIZE", + "__GL_SHADER_DISK_CACHE_SKIP_CLEANUP" + ] + ); + assert!(p.notes.is_empty()); + } + + #[test] + fn what_the_user_set_is_not_overridden() { + let set = |n: &str| n == "MESA_SHADER_CACHE_MAX_SIZE"; + let p = plan(&opts(&["g"]), None, &machine(), &set); + assert!(!p.env.iter().any(|(k, _)| k == "MESA_SHADER_CACHE_MAX_SIZE")); + assert!( + p.env + .iter() + .any(|(k, _)| k == "__GL_SHADER_DISK_CACHE_SIZE") + ); + } + + #[test] + fn no_boost_and_keep_leave_the_governor_alone() { + let mut o = opts(&["g"]); + o.no_boost = true; + assert_eq!(plan(&o, None, &machine(), &none_set).governor, None); + let profiles = parse("[[game]]\nmatch = \"g\"\ngovernor = \"keep\"\n").unwrap(); + assert_eq!( + plan(&opts(&["g"]), profiles.first(), &machine(), &none_set).governor, + None + ); + let profiles = parse("[[game]]\nmatch = \"g\"\ngovernor = \"schedutil\"\n").unwrap(); + assert_eq!( + plan(&opts(&["g"]), profiles.first(), &machine(), &none_set) + .governor + .as_deref(), + Some("schedutil") + ); + } + + #[test] + fn pinning_to_the_vcache_complex() { + let profiles = parse("[[game]]\nmatch = \"g\"\npin = \"vcache\"\n").unwrap(); + let p = plan(&opts(&["g"]), profiles.first(), &machine(), &none_set); + assert_eq!(p.cpus, Some(vec![0, 1, 2, 3])); + // No such complex: a note, no pin. + let none = Machine { + vcache: None, + ..machine() + }; + let p = plan(&opts(&["g"]), profiles.first(), &none, &none_set); + assert_eq!(p.cpus, None); + assert!(p.notes[0].contains("no V-Cache"), "{:?}", p.notes); + // The command line beats the profile. + let mut o = opts(&["g"]); + o.pin = Some(Pin::Off); + assert_eq!(plan(&o, profiles.first(), &machine(), &none_set).cpus, None); + o.pin = Some(Pin::Cpus(vec![8, 9])); + assert_eq!(plan(&o, None, &machine(), &none_set).cpus, Some(vec![8, 9])); + } + + #[test] + fn mangohud_and_gamescope_wrap_the_game() { + let profiles = parse("[[game]]\nmatch = \"g\"\nmangohud = true\nmangohud_config = \"preset\"\ngamescope = [\"-W\", \"2560\", \"--adaptive-sync\"]\n").unwrap(); + let p = plan(&opts(&["g", "-x"]), profiles.first(), &machine(), &none_set); + assert_eq!( + p.argv, + [ + "gamescope", + "-W", + "2560", + "--adaptive-sync", + "--", + "mangohud", + "g", + "-x" + ] + ); + assert!( + p.env + .contains(&("MANGOHUD_CONFIGFILE".into(), PRESET.into())) + ); + // Missing tools are said, not fatal. + let bare = Machine { + mangohud: false, + gamescope: false, + ..machine() + }; + let p = plan(&opts(&["g"]), profiles.first(), &bare, &none_set); + assert_eq!(p.argv, ["g"]); + assert_eq!(p.notes.len(), 2); + // The user's own MangoHud config wins. + let set = |n: &str| n == "MANGOHUD_CONFIGFILE"; + let p = plan(&opts(&["g"]), profiles.first(), &machine(), &set); + assert!(!p.env.iter().any(|(k, _)| k == "MANGOHUD_CONFIGFILE")); + } + + #[test] + fn nice_comes_from_the_flag_then_the_profile_then_the_default() { + let profiles = parse("[[game]]\nmatch = \"g\"\nnice = -9\n").unwrap(); + assert_eq!( + plan(&opts(&["g"]), profiles.first(), &machine(), &none_set).nice, + -9 + ); + let mut o = opts(&["g"]); + o.nice = Some(2); + assert_eq!(plan(&o, profiles.first(), &machine(), &none_set).nice, 2); + assert_eq!(plan(&opts(&["g"]), None, &machine(), &none_set).nice, -4); + } + + #[test] + fn the_command_after_dashes_is_taken_whole() { + let mut p = + lexopt::Parser::from_args(["--no-boost", "--", "proton", "run", "--weird", "-x"]); + let o = parse_args(&mut p).unwrap(); + assert!(o.no_boost); + assert_eq!(o.command, ["proton", "run", "--weird", "-x"]); + let mut p = lexopt::Parser::from_args(["--pin", "0-3", "game"]); + let o = parse_args(&mut p).unwrap(); + assert_eq!(o.pin, Some(Pin::Cpus(vec![0, 1, 2, 3]))); + assert_eq!(o.command, ["game"]); + } + + #[test] + fn usage_mistakes() { + for args in [ + vec![], + vec!["--no-boost"], + vec!["--nice", "99", "--", "g"], + vec!["--pin", "left", "--", "g"], + vec!["--bogus", "--", "g"], + ] { + let mut p = lexopt::Parser::from_args(args.clone()); + assert!( + matches!(parse_args(&mut p), Err(Error::Usage(_))), + "{args:?}" + ); + } + } + + #[test] + fn programs_are_found_before_anything_is_changed() { + assert!(program_exists("sh")); + assert!(program_exists("/bin/sh")); + assert!(!program_exists("definitely-not-a-game-seg")); + assert!(!program_exists("/no/such/game")); + // A file that cannot be run. + assert!(!program_exists("/etc/hostname")); + } + + #[test] + fn the_plan_reads_well() { + let profiles = parse("[[game]]\nmatch = \"g\"\npin = \"vcache\"\n").unwrap(); + let p = plan(&opts(&["g"]), profiles.first(), &machine(), &none_set); + let text = describe(&p, profiles.first()); + assert!(text.contains("profile: match \"g\" (line 1)"), "{text}"); + assert!(text.contains("pin: 0-3"), "{text}"); + assert!(text.contains("command: g"), "{text}"); + } +} diff --git a/tools/seg/segctl/src/game/setup.rs b/tools/seg/segctl/src/game/setup.rs new file mode 100644 index 0000000..d1c380e --- /dev/null +++ b/tools/seg/segctl/src/game/setup.rs @@ -0,0 +1,451 @@ +//! `seg game setup`: reads the graphics cards from /sys/class/drm (and their +//! names from lspci), says which packages this computer needs for games, and +//! with `--yes` installs them. Only drivers for hardware that is there. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use super::check::installed_packages; +use super::hw::{self, Gpu, GpuVendor}; +use crate::error::{Error, Result}; +use crate::sys; + +pub const HELP: &str = "\ +Usage: seg game setup [--yes] [--games] [--lutris] + +Finds the graphics cards in this computer (/sys/class/drm, names from lspci) +and lists the packages they need to play: the Mesa and Vulkan drivers and +their 32-bit versions for AMD and Intel, NVIDIA's open kernel driver (with +the headers of the running kernel, for DKMS) and utilities for GeForce GTX 16 +and RTX cards, the open-source driver for older NVIDIA cards. + +Without --yes it only prints them and the command that installs them. With +--yes (as root) it runs pacman -S --needed --noconfirm with that list. + + --games also Steam, gamescope, MangoHud (64 and 32 bit), Wine Staging + and winetricks + --lutris also Lutris (a big package; Steam does not need it) +"; + +#[derive(Debug, Default)] +pub struct Options { + pub yes: bool, + pub games: bool, + pub lutris: bool, +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut o = Options::default(); + while let Some(arg) = parser.next()? { + match arg { + Long("yes") | Short('y') => o.yes = true, + Long("games") => o.games = true, + Long("lutris") => o.lutris = true, + Short('h') | Long("help") => { + out!("{HELP}"); + std::process::exit(0); + } + other => return Err(other.unexpected().into()), + } + } + Ok(o) +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Plan { + /// (what it is called, the packages for it) + pub per_gpu: Vec<(String, Vec)>, + /// Everything to install, in order, without repeats. + pub packages: Vec, + pub notes: Vec, + pub needs_nvidia_modprobe: bool, +} + +fn strings(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() +} + +/// The packages for one card. `kernel` is the package of the running kernel. +pub fn packages_for(gpu: &Gpu, kernel: &str) -> (Vec, Option<&'static str>) { + match gpu.vendor { + GpuVendor::Amd => ( + strings(&["mesa", "lib32-mesa", "vulkan-radeon", "lib32-vulkan-radeon"]), + None, + ), + GpuVendor::Intel => ( + strings(&["mesa", "lib32-mesa", "vulkan-intel", "lib32-vulkan-intel"]), + None, + ), + GpuVendor::Nvidia if gpu.nvidia_open_capable() => ( + vec![ + format!("{kernel}-headers"), + "nvidia-open-dkms".into(), + "nvidia-utils".into(), + "lib32-nvidia-utils".into(), + "nvidia-settings".into(), + ], + None, + ), + GpuVendor::Nvidia => ( + strings(&["mesa", "lib32-mesa", "vulkan-nouveau"]), + Some( + "this NVIDIA card is older than Turing, which NVIDIA's current driver no longer supports; the open-source NVK driver is used", + ), + ), + GpuVendor::Other => (Vec::new(), Some("no driver known for this graphics card")), + } +} + +/// `[lib32]` enabled in pacman.conf? +pub fn lib32_enabled(pacman_conf: &str) -> bool { + pacman_conf.lines().any(|l| l.trim() == "[lib32]") +} + +pub fn plan( + gpus: &[Gpu], + describe: &dyn Fn(&Gpu) -> String, + kernel: Option<&str>, + games: bool, + lutris: bool, +) -> Plan { + let kernel = kernel.unwrap_or("linux"); + let mut plan = Plan { + per_gpu: Vec::new(), + packages: Vec::new(), + notes: Vec::new(), + needs_nvidia_modprobe: false, + }; + let mut seen = BTreeSet::new(); + let mut add = |plan: &mut Plan, pkg: &str| { + if seen.insert(pkg.to_string()) { + plan.packages.push(pkg.to_string()); + } + }; + if gpus.is_empty() { + plan.notes.push("no graphics card found in /sys/class/drm (a virtual machine without GPU?); no driver to install".into()); + } + for gpu in gpus { + let (pkgs, note) = packages_for(gpu, kernel); + if let Some(n) = note { + plan.notes.push(format!("{}: {n}", describe(gpu))); + } + if gpu.nvidia_open_capable() { + plan.needs_nvidia_modprobe = true; + } + for p in &pkgs { + add(&mut plan, p); + } + plan.per_gpu.push((describe(gpu), pkgs)); + } + if !gpus.is_empty() { + for p in [ + "vulkan-icd-loader", + "lib32-vulkan-icd-loader", + "vulkan-tools", + ] { + add(&mut plan, p); + } + } + if games { + for p in [ + "steam", + "gamescope", + "mangohud", + "lib32-mangohud", + "wine-staging", + "winetricks", + ] { + add(&mut plan, p); + } + } + if lutris { + add(&mut plan, "lutris"); + } + plan +} + +pub fn render(plan: &Plan, installed: &BTreeSet) -> String { + let mut text = String::new(); + for (name, pkgs) in &plan.per_gpu { + text.push_str(&format!( + "{name}\n {}\n", + if pkgs.is_empty() { + "(nothing)".to_string() + } else { + pkgs.join(" ") + } + )); + } + for n in &plan.notes { + text.push_str(&format!("note: {n}\n")); + } + let todo: Vec<&String> = plan + .packages + .iter() + .filter(|p| !installed.contains(*p)) + .collect(); + if plan.packages.is_empty() { + text.push_str("nothing to install\n"); + } else if todo.is_empty() { + text.push_str(&format!( + "all {} packages are installed already\n", + plan.packages.len() + )); + } else { + text.push_str(&format!( + "\nto install ({} of {}):\n sudo pacman -S --needed {}\n", + todo.len(), + plan.packages.len(), + todo.iter() + .map(|s| s.as_str()) + .collect::>() + .join(" ") + )); + } + text +} + +/// The modprobe options NVIDIA's DRM driver wants for Wayland and a +/// framebuffer console. +pub const NVIDIA_MODPROBE: &str = "# Written by seg game setup: kernel mode setting for Wayland, and the\n# framebuffer console on NVIDIA's driver.\noptions nvidia_drm modeset=1 fbdev=1\n"; + +/// Is a modprobe file with these options already there (the seglinux package +/// ships one)? +pub fn has_nvidia_modprobe(root: &Path) -> bool { + ["usr/lib/modprobe.d", "etc/modprobe.d"].iter().any(|dir| { + fs::read_dir(root.join(dir)).is_ok_and(|d| { + d.flatten().any(|e| { + fs::read_to_string(e.path()).is_ok_and(|t| { + t.lines().any(|l| { + l.trim().starts_with("options nvidia_drm") && l.contains("modeset=1") + }) + }) + }) + }) + }) +} + +pub fn run(o: Options) -> Result { + let root = Path::new("/"); + let gpus = hw::gpus(root); + let kernel = hw::kernel_package(root); + let p = plan(&gpus, &hw::describe, kernel.as_deref(), o.games, o.lutris); + let installed = installed_packages(&root.join("var/lib/pacman/local")); + out!("{}", render(&p, &installed)); + let todo: Vec<&str> = p + .packages + .iter() + .filter(|x| !installed.contains(*x)) + .map(String::as_str) + .collect(); + let needs_modprobe = p.needs_nvidia_modprobe && !has_nvidia_modprobe(root); + if !o.yes { + if !todo.is_empty() || needs_modprobe { + outln!("\nRun `sudo seg game setup --yes` to install them."); + } + return Ok(0); + } + sys::require_root("`seg game setup --yes`")?; + let conf = fs::read_to_string("/etc/pacman.conf").unwrap_or_default(); + if p.packages.iter().any(|x| x.starts_with("lib32-")) && !lib32_enabled(&conf) { + return Err(Error::other( + "the [lib32] repository is not enabled in /etc/pacman.conf: add\n\n [lib32]\n Include = /etc/pacman.d/mirrorlist\n\nand run `sudo pacman -Sy`, then try again", + )); + } + if !todo.is_empty() { + let mut args = vec!["-S", "--needed", "--noconfirm"]; + args.extend(todo.iter().copied()); + sys::run_inherit("pacman", &args)?; + } + if needs_modprobe { + sys::write("/etc/modprobe.d/seg-nvidia.conf", NVIDIA_MODPROBE)?; + outln!("wrote /etc/modprobe.d/seg-nvidia.conf"); + } + if p.needs_nvidia_modprobe { + outln!("\nReboot to use NVIDIA's driver."); + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::hw::{fixture, gpus}; + + fn name(g: &Gpu) -> String { + format!("{} {:04x}", g.card, g.device_id) + } + + fn p(fixture_name: &str) -> Plan { + plan( + &gpus(&fixture(fixture_name)), + &name, + Some("linux-zen"), + false, + false, + ) + } + + #[test] + fn amd_gets_mesa_and_radv_in_both_bitnesses() { + let plan = p("gpu-amd"); + assert_eq!( + plan.packages, + [ + "mesa", + "lib32-mesa", + "vulkan-radeon", + "lib32-vulkan-radeon", + "vulkan-icd-loader", + "lib32-vulkan-icd-loader", + "vulkan-tools" + ] + ); + assert!(!plan.needs_nvidia_modprobe); + assert!(plan.notes.is_empty()); + } + + #[test] + fn intel_gets_anv() { + let plan = p("gpu-intel-arc"); + assert!(plan.packages.contains(&"vulkan-intel".to_string())); + assert!(plan.packages.contains(&"lib32-vulkan-intel".to_string())); + assert!( + !plan + .packages + .iter() + .any(|x| x.contains("nvidia") || x.contains("radeon")) + ); + } + + #[test] + fn a_turing_or_newer_nvidia_gets_the_open_dkms_driver_and_the_running_kernels_headers() { + let plan = p("gpu-nvidia-ada-nouveau"); + assert_eq!( + &plan.packages[..5], + [ + "linux-zen-headers", + "nvidia-open-dkms", + "nvidia-utils", + "lib32-nvidia-utils", + "nvidia-settings" + ] + ); + assert!(plan.needs_nvidia_modprobe); + // No Mesa packages the proprietary driver does not use. + assert!(!plan.packages.contains(&"vulkan-radeon".to_string())); + } + + #[test] + fn an_old_nvidia_card_stays_on_nouveau_and_says_why() { + let plan = p("gpu-nvidia-pascal"); + assert!(plan.packages.contains(&"vulkan-nouveau".to_string())); + assert!(!plan.packages.iter().any(|x| x.contains("nvidia"))); + assert!(!plan.needs_nvidia_modprobe); + assert!( + plan.notes[0].contains("older than Turing"), + "{:?}", + plan.notes + ); + } + + #[test] + fn a_hybrid_laptop_gets_both_without_repeats() { + let plan = p("gpu-hybrid-laptop"); + assert_eq!(plan.per_gpu.len(), 2); + let count = |x: &str| plan.packages.iter().filter(|p| *p == x).count(); + assert_eq!(count("lib32-mesa"), 1); + assert_eq!(count("vulkan-icd-loader"), 1); + assert_eq!(count("nvidia-open-dkms"), 1); + assert_eq!(count("vulkan-intel"), 1); + } + + #[test] + fn no_gpu_installs_no_driver() { + let plan = p("gpu-none"); + assert!(plan.packages.is_empty()); + assert!( + plan.notes[0].contains("no graphics card"), + "{:?}", + plan.notes + ); + let text = render(&plan, &BTreeSet::new()); + assert!(text.contains("nothing to install"), "{text}"); + // The games stack can still be asked for. + let with = plan_games(); + assert!(with.packages.contains(&"steam".to_string())); + } + + fn plan_games() -> Plan { + plan(&[], &name, None, true, true) + } + + #[test] + fn the_games_stack_and_lutris_are_opt_in() { + let base = plan(&gpus(&fixture("gpu-amd")), &name, None, false, false); + assert!(!base.packages.contains(&"steam".to_string())); + assert!(!base.packages.contains(&"lutris".to_string())); + let all = plan(&gpus(&fixture("gpu-amd")), &name, None, true, true); + for pkg in [ + "steam", + "gamescope", + "mangohud", + "lib32-mangohud", + "wine-staging", + "winetricks", + "lutris", + ] { + assert!(all.packages.contains(&pkg.to_string()), "{pkg}"); + } + let games_only = plan_games(); + assert_eq!( + games_only.packages.last().map(String::as_str), + Some("lutris") + ); + } + + #[test] + fn an_unknown_running_kernel_means_the_stock_headers() { + let plan = plan(&gpus(&fixture("gpu-nvidia-ada")), &name, None, false, false); + assert_eq!(plan.packages[0], "linux-headers"); + } + + #[test] + fn the_report_lists_only_what_is_missing() { + let plan = p("gpu-amd"); + let have: BTreeSet = ["mesa", "lib32-mesa"] + .iter() + .map(|s| s.to_string()) + .collect(); + let text = render(&plan, &have); + assert!(text.contains("to install (5 of 7)"), "{text}"); + assert!( + text.contains("sudo pacman -S --needed vulkan-radeon lib32-vulkan-radeon"), + "{text}" + ); + let all: BTreeSet = plan.packages.iter().cloned().collect(); + assert!(render(&plan, &all).contains("all 7 packages are installed already")); + } + + #[test] + fn the_lib32_repository_and_the_nvidia_options() { + assert!(lib32_enabled("[options]\n[system]\n[lib32]\nInclude = x\n")); + assert!(!lib32_enabled("[options]\n#[lib32]\n")); + let t = sys::testutil::TempDir::new(); + assert!(!has_nvidia_modprobe(t.path())); + t.put( + "usr/lib/modprobe.d/seglinux-nvidia.conf", + "options nvidia_drm modeset=1 fbdev=1\n", + ); + assert!(has_nvidia_modprobe(t.path())); + let t = sys::testutil::TempDir::new(); + t.put( + "etc/modprobe.d/other.conf", + "options nvidia_drm modeset=0\n", + ); + assert!(!has_nvidia_modprobe(t.path())); + assert!(NVIDIA_MODPROBE.contains("options nvidia_drm modeset=1 fbdev=1")); + } +} diff --git a/tools/seg/segctl/src/game/toml.rs b/tools/seg/segctl/src/game/toml.rs new file mode 100644 index 0000000..1078945 --- /dev/null +++ b/tools/seg/segctl/src/game/toml.rs @@ -0,0 +1,371 @@ +//! The small part of TOML that ~/.config/seg/games.toml uses: `[[game]]` +//! tables with string, integer, boolean and string-array values, comments +//! and blank lines. Anything else is an error that names the line, so a typo +//! is not silently ignored. + +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Value { + Str(String), + Int(i64), + Bool(bool), + Array(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Table { + /// The `[[name]]` it belongs to; empty before the first header. + pub name: String, + pub line: usize, + pub entries: Vec<(String, Value, usize)>, +} + +impl Table { + pub fn get(&self, key: &str) -> Option<&Value> { + self.entries + .iter() + .find(|(k, _, _)| k == key) + .map(|(_, v, _)| v) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ParseError { + pub line: usize, + pub message: String, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "line {}: {}", self.line, self.message) + } +} + +fn err(line: usize, message: impl Into) -> Result { + Err(ParseError { + line, + message: message.into(), + }) +} + +/// Cut a `#` comment off a line, unless the `#` is inside a string. +fn strip_comment(line: &str) -> &str { + let mut in_basic = false; + let mut in_literal = false; + let mut escaped = false; + for (i, c) in line.char_indices() { + match c { + '\\' if in_basic && !escaped => { + escaped = true; + continue; + } + '"' if !in_literal && !escaped => in_basic = !in_basic, + '\'' if !in_basic => in_literal = !in_literal, + '#' if !in_basic && !in_literal => return line.get(..i).unwrap_or(line), + _ => {} + } + escaped = false; + } + line +} + +fn parse_string(text: &str, line: usize) -> Result<(String, &str), ParseError> { + let mut chars = text.char_indices(); + let quote = match chars.next() { + Some((_, q @ ('"' | '\''))) => q, + _ => return err(line, "expected a string"), + }; + let mut out = String::new(); + let mut escaped = false; + for (i, c) in chars { + if quote == '\'' { + if c == '\'' { + return Ok((out, text.get(i + 1..).unwrap_or(""))); + } + out.push(c); + } else if escaped { + out.push(match c { + 'n' => '\n', + 't' => '\t', + '"' => '"', + '\\' => '\\', + other => return err(line, format!("unknown escape \\{other}")), + }); + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + return Ok((out, text.get(i + 1..).unwrap_or(""))); + } else { + out.push(c); + } + } + err(line, "a string is not closed") +} + +/// One value from the start of `text`, and what follows it. +fn parse_value(text: &str, line: usize) -> Result<(Value, &str), ParseError> { + let text = text.trim_start(); + match text.chars().next() { + Some('"' | '\'') => { + let (s, rest) = parse_string(text, line)?; + Ok((Value::Str(s), rest)) + } + Some('[') => { + let mut items = Vec::new(); + let mut rest = text.get(1..).unwrap_or("").trim_start(); + loop { + if let Some(after) = rest.strip_prefix(']') { + return Ok((Value::Array(items), after)); + } + let (item, after) = parse_value(rest, line)?; + if matches!(item, Value::Array(_)) { + return err(line, "arrays inside arrays are not supported"); + } + items.push(item); + rest = after.trim_start(); + if let Some(after) = rest.strip_prefix(',') { + rest = after.trim_start(); + } else if !rest.starts_with(']') { + return err(line, "expected `,` or `]` in an array"); + } + } + } + Some(_) => { + let end = text + .find(|c: char| c == ',' || c == ']' || c.is_whitespace()) + .unwrap_or(text.len()); + let (word, rest) = (text.get(..end).unwrap_or(""), text.get(end..).unwrap_or("")); + let value = match word { + "true" => Value::Bool(true), + "false" => Value::Bool(false), + _ => match word.replace('_', "").parse::() { + Ok(n) => Value::Int(n), + Err(_) => { + return err( + line, + format!("`{word}` is not a value (strings need quotes)"), + ); + } + }, + }; + Ok((value, rest)) + } + None => err(line, "a value is missing"), + } +} + +fn valid_key(key: &str) -> bool { + !key.is_empty() + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// Parse a document into its tables, in order. +pub fn parse(text: &str) -> Result, ParseError> { + let mut tables = vec![Table::default()]; + // A multi-line array being read: key, the text so far, its first line. + let mut pending: Option<(String, String, usize)> = None; + for (index, raw) in text.lines().enumerate() { + let line_no = index + 1; + let line = strip_comment(raw).trim(); + let (key, value_text, first_line) = match pending.take() { + Some((key, mut so_far, first)) => { + so_far.push(' '); + so_far.push_str(line); + (key, so_far, first) + } + None => { + if line.is_empty() { + continue; + } + if let Some(name) = line.strip_prefix("[[") { + let Some(name) = name.strip_suffix("]]") else { + return err(line_no, "a table header must end with `]]`"); + }; + let name = name.trim(); + if !valid_key(name) { + return err(line_no, format!("`{name}` is not a table name")); + } + tables.push(Table { + name: name.to_string(), + line: line_no, + entries: Vec::new(), + }); + continue; + } + if line.starts_with('[') { + return err(line_no, "only [[table]] headers are supported"); + } + let Some((key, value)) = line.split_once('=') else { + return err(line_no, "expected `key = value`"); + }; + let key = key.trim(); + if !valid_key(key) { + return err(line_no, format!("`{key}` is not a key")); + } + (key.to_string(), value.trim().to_string(), line_no) + } + }; + // An array whose `]` has not come yet continues on the next line. + if value_text.starts_with('[') && !array_is_closed(&value_text) { + pending = Some((key, value_text, first_line)); + continue; + } + let (value, rest) = parse_value(&value_text, first_line)?; + if !rest.trim().is_empty() { + return err( + first_line, + format!("unexpected `{}` after the value", rest.trim()), + ); + } + let Some(table) = tables.last_mut() else { + return err(first_line, "no table"); + }; + if table.get(&key).is_some() { + return err(first_line, format!("`{key}` is set twice")); + } + table.entries.push((key, value, first_line)); + } + if let Some((_, _, first)) = pending { + return err(first, "an array is not closed"); + } + // The part before the first header must be empty. + if let Some(first) = tables.first() + && let Some((key, _, line)) = first.entries.first() + { + return err(*line, format!("`{key}` is outside a [[game]] table")); + } + tables.remove(0); + Ok(tables) +} + +/// Whether the `[` at the start of `text` has its closing `]` (brackets in +/// strings do not count). +fn array_is_closed(text: &str) -> bool { + let mut depth = 0i32; + let mut quote: Option = None; + let mut escaped = false; + for c in text.chars() { + match quote { + Some(q) => { + if q == '"' && c == '\\' && !escaped { + escaped = true; + continue; + } + if c == q && !escaped { + quote = None; + } + escaped = false; + } + None => match c { + '"' | '\'' => quote = Some(c), + '[' => depth += 1, + ']' => depth -= 1, + _ => {} + }, + } + } + depth <= 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn s(v: &str) -> Value { + Value::Str(v.into()) + } + + #[test] + fn tables_and_values() { + let doc = r#" +# my games +[[game]] +match = "eldenring.exe" # the executable +governor = 'performance' +nice = -5 +mangohud = true +gamescope = ["-W", "2560", "-H", "1440"] + +[[game]] +match = "cs2" +pin = "0-7,16-23" +"#; + let t = parse(doc).unwrap(); + assert_eq!(t.len(), 2); + assert_eq!(t[0].name, "game"); + assert_eq!(t[0].line, 3); + assert_eq!(t[0].get("match"), Some(&s("eldenring.exe"))); + assert_eq!(t[0].get("governor"), Some(&s("performance"))); + assert_eq!(t[0].get("nice"), Some(&Value::Int(-5))); + assert_eq!(t[0].get("mangohud"), Some(&Value::Bool(true))); + assert_eq!( + t[0].get("gamescope"), + Some(&Value::Array(vec![s("-W"), s("2560"), s("-H"), s("1440")])) + ); + assert_eq!(t[1].get("pin"), Some(&s("0-7,16-23"))); + assert_eq!(t[1].get("governor"), None); + } + + #[test] + fn strings_keep_hashes_and_escapes() { + let t = parse("[[game]]\nmatch = \"a#b\" # comment\nname = \"say \\\"hi\\\"\\n\"\nlit = 'C:\\path # x'\n").unwrap(); + assert_eq!(t[0].get("match"), Some(&s("a#b"))); + assert_eq!(t[0].get("name"), Some(&s("say \"hi\"\n"))); + assert_eq!(t[0].get("lit"), Some(&s("C:\\path # x"))); + } + + #[test] + fn arrays_may_span_lines_and_end_with_a_comma() { + let doc = "[[game]]\nmatch = \"x\"\ngamescope = [\n \"-W\", \"1920\", # width\n \"--adaptive-sync\",\n]\nempty = []\n"; + let t = parse(doc).unwrap(); + assert_eq!( + t[0].get("gamescope"), + Some(&Value::Array(vec![ + s("-W"), + s("1920"), + s("--adaptive-sync") + ])) + ); + assert_eq!(t[0].get("empty"), Some(&Value::Array(vec![]))); + assert_eq!( + t[0].entries + .iter() + .find(|e| e.0 == "gamescope") + .map(|e| e.2), + Some(3) + ); + } + + #[test] + fn mistakes_name_their_line() { + let cases = [ + ("[[game]]\nmatch = eldenring\n", 2, "strings need quotes"), + ("[[game]]\nmatch\n", 2, "expected `key = value`"), + ("[game]\n", 1, "only [[table]] headers"), + ("[[game]\n", 1, "must end with `]]`"), + ("[[game]]\nmatch = \"a\nx = 1\n", 2, "not closed"), + ("[[game]]\na = 1\na = 2\n", 3, "set twice"), + ("[[game]]\na = [1, 2\n", 2, "not closed"), + ("[[game]]\na = [[1]]\n", 2, "inside arrays"), + ("[[game]]\na = 1 2\n", 2, "unexpected"), + ("a = 1\n", 1, "outside a [[game]] table"), + ("[[game]]\n= 1\n", 2, "is not a key"), + ("[[game]]\na = \"\\q\"\n", 2, "unknown escape"), + ]; + for (doc, line, fragment) in cases { + let e = parse(doc).unwrap_err(); + assert_eq!(e.line, line, "{doc:?} -> {e}"); + assert!(e.to_string().contains(fragment), "{doc:?} -> {e}"); + } + } + + #[test] + fn empty_documents_have_no_tables() { + assert_eq!(parse("").unwrap(), vec![]); + assert_eq!(parse("# only a comment\n\n").unwrap(), vec![]); + } +} diff --git a/tools/seg/segctl/src/game/unsafe_boost.rs b/tools/seg/segctl/src/game/unsafe_boost.rs new file mode 100644 index 0000000..78c8ba1 --- /dev/null +++ b/tools/seg/segctl/src/game/unsafe_boost.rs @@ -0,0 +1,311 @@ +//! `seg game unsafe-boost`: `mitigations=off` on the kernel command line. +//! Opt-in, with a warning, never done by anything else in SegLinux. + +use std::fs; +use std::path::Path; + +use crate::error::{Error, Result}; +use crate::sys; + +pub const HELP: &str = "\ +Usage: seg game unsafe-boost on|off|status [--i-understand] + +Adds or removes `mitigations=off` in GRUB_CMDLINE_LINUX_DEFAULT of +/etc/default/grub and rebuilds the boot menu. It takes effect at the next +boot. `on` only runs with --i-understand, after you have read this: +"; + +pub const WARNING: &str = "\ +WARNING: mitigations=off switches off the kernel's protections against CPU +security flaws (Spectre, Meltdown, MDS, Retbleed and the others). While it is +on, any program on this computer, including JavaScript in a web page, may be +able to read memory it must not read: passwords, keys, other programs' data. + +The speed it gives back is usually small, bigger on older CPUs, and nothing at +all in many games. Measure first (docs/gaming.md has a method) and keep it +only if the numbers are worth it to you. Do not use it on a computer you share, +or use for banking or work. + +SegLinux never turns this on by itself. `seg game unsafe-boost off` undoes it. +"; + +const PARAM: &str = "mitigations=off"; +const KEY: &str = "GRUB_CMDLINE_LINUX_DEFAULT"; + +/// The words inside the quotes of `KEY="..."`, and the quote used. +fn split_value(value: &str) -> Option<(char, Vec<&str>)> { + let quote = value.chars().next().filter(|c| *c == '"' || *c == '\'')?; + let inner = value.strip_prefix(quote)?.strip_suffix(quote)?; + Some((quote, inner.split_whitespace().collect())) +} + +/// /etc/default/grub with `param` added (`add`) or removed. The bool says +/// whether anything changed. +pub fn edit_grub(text: &str, param: &str, add: bool) -> (String, bool) { + let mut changed = false; + let mut found = false; + let mut lines: Vec = text + .lines() + .map(|line| { + let Some(value) = line + .trim_start() + .strip_prefix(KEY) + .and_then(|r| r.strip_prefix('=')) + else { + return line.to_string(); + }; + let Some((quote, mut words)) = split_value(value.trim()) else { + return line.to_string(); + }; + found = true; + let present = words.contains(¶m); + if add && !present { + words.push(param); + changed = true; + } else if !add && present { + words.retain(|w| *w != param); + changed = true; + } else { + return line.to_string(); + } + format!("{KEY}={quote}{}{quote}", words.join(" ")) + }) + .collect(); + if add && !found { + lines.push(format!("{KEY}=\"{param}\"")); + changed = true; + } + let mut out = lines.join("\n"); + if text.ends_with('\n') || (add && !found && !text.is_empty()) { + out.push('\n'); + } + (out, changed) +} + +/// Whether the configured and the running command lines have the parameter. +pub fn status(grub: &str, cmdline: &str) -> (bool, bool) { + let configured = grub + .lines() + .filter_map(|l| l.trim_start().strip_prefix(KEY)?.strip_prefix('=')) + .filter_map(|v| split_value(v.trim())) + .any(|(_, words)| words.contains(&PARAM)); + let running = cmdline.split_whitespace().any(|w| w == PARAM); + (configured, running) +} + +pub enum Action { + On { understood: bool }, + Off, + Status, +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut word: Option = None; + let mut understood = false; + while let Some(arg) = parser.next()? { + match arg { + Long("i-understand") => understood = true, + Short('h') | Long("help") => { + out!("{HELP}\n{WARNING}"); + std::process::exit(0); + } + Value(v) if word.is_none() => word = Some(v.string()?), + other => return Err(other.unexpected().into()), + } + } + match word.as_deref() { + Some("on") => Ok(Action::On { understood }), + Some("off") => Ok(Action::Off), + None | Some("status") => Ok(Action::Status), + Some(other) => Err(Error::Usage(format!( + "unknown unsafe-boost command `{other}` (on, off or status)" + ))), + } +} + +/// Edit the file under `root` and keep the old one next to it. +pub fn apply(root: &Path, add: bool) -> Result { + let path = root.join("etc/default/grub"); + let text = sys::read(&path)?; + let (new, changed) = edit_grub(&text, PARAM, add); + if changed { + let mut backup = path.as_os_str().to_owned(); + backup.push(".seg-bak"); + fs::write(&backup, &text) + .map_err(|e| Error::io("saving a copy of /etc/default/grub", e))?; + sys::write_atomic(&path, &new)?; + } + Ok(changed) +} + +pub fn run(action: Action) -> Result { + let root = Path::new("/"); + match action { + Action::Status => { + let grub = sys::read_trimmed("/etc/default/grub").unwrap_or_default(); + let cmdline = sys::read_trimmed("/proc/cmdline").unwrap_or_default(); + let (configured, running) = status(&grub, &cmdline); + outln!( + "mitigations=off in /etc/default/grub: {}", + if configured { "yes" } else { "no" } + ); + outln!( + "mitigations=off on the running kernel: {}", + if running { + "yes (the security mitigations are OFF)" + } else { + "no" + } + ); + Ok(0) + } + Action::On { understood } => { + out!("{WARNING}\n"); + if !understood { + outln!( + "Nothing was changed. To turn it on anyway:\n sudo seg game unsafe-boost on --i-understand" + ); + return Ok(1); + } + sys::require_root("`seg game unsafe-boost on`")?; + rebuild_menu(root, true) + } + Action::Off => { + sys::require_root("`seg game unsafe-boost off`")?; + rebuild_menu(root, false) + } + } +} + +fn rebuild_menu(root: &Path, add: bool) -> Result { + if apply(root, add)? { + if sys::which("grub-mkconfig").is_some() { + sys::run_inherit("grub-mkconfig", &["-o", "/boot/grub/grub.cfg"])?; + } else { + outln!("grub-mkconfig is not installed: rebuild your boot menu yourself."); + } + outln!( + "mitigations=off is now {} in the boot menu. Reboot for it to take effect. (A copy of the old /etc/default/grub is /etc/default/grub.seg-bak.)", + if add { "ON" } else { "off" } + ); + } else { + outln!( + "nothing to change: mitigations=off is already {}.", + if add { "on" } else { "off" } + ); + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + const GRUB: &str = "GRUB_DEFAULT=0\n#GRUB_CMDLINE_LINUX_DEFAULT=\"old\"\nGRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog\"\nGRUB_CMDLINE_LINUX=\"\"\n"; + + #[test] + fn adding_keeps_the_rest_of_the_line_and_file() { + let (new, changed) = edit_grub(GRUB, PARAM, true); + assert!(changed); + assert!( + new.contains( + "GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog mitigations=off\"\n" + ), + "{new}" + ); + assert!( + new.contains("#GRUB_CMDLINE_LINUX_DEFAULT=\"old\"\n"), + "comments are untouched" + ); + assert!(new.contains("GRUB_CMDLINE_LINUX=\"\"\n")); + assert!(new.ends_with('\n')); + } + + #[test] + fn it_is_idempotent_both_ways() { + let (on, _) = edit_grub(GRUB, PARAM, true); + assert_eq!(edit_grub(&on, PARAM, true), (on.clone(), false)); + let (off, changed) = edit_grub(&on, PARAM, false); + assert!(changed); + assert_eq!(off, GRUB); + assert_eq!(edit_grub(GRUB, PARAM, false), (GRUB.to_string(), false)); + } + + #[test] + fn single_quotes_and_a_missing_line() { + let (new, _) = edit_grub("GRUB_CMDLINE_LINUX_DEFAULT='quiet'\n", PARAM, true); + assert_eq!(new, "GRUB_CMDLINE_LINUX_DEFAULT='quiet mitigations=off'\n"); + let (new, changed) = edit_grub("GRUB_DEFAULT=0\n", PARAM, true); + assert!(changed); + assert_eq!( + new, + "GRUB_DEFAULT=0\nGRUB_CMDLINE_LINUX_DEFAULT=\"mitigations=off\"\n" + ); + // Removing from a file without the line does nothing. + assert_eq!( + edit_grub("GRUB_DEFAULT=0\n", PARAM, false), + ("GRUB_DEFAULT=0\n".to_string(), false) + ); + // A value that is not quoted is not guessed at. + assert_eq!( + edit_grub("GRUB_CMDLINE_LINUX_DEFAULT=quiet\n", PARAM, false), + ("GRUB_CMDLINE_LINUX_DEFAULT=quiet\n".to_string(), false) + ); + } + + #[test] + fn a_similar_word_is_not_the_parameter() { + let g = "GRUB_CMDLINE_LINUX_DEFAULT=\"quiet nomitigations=off2 xmitigations=off\"\n"; + assert_eq!(edit_grub(g, PARAM, false), (g.to_string(), false)); + assert_eq!(status(g, "quiet"), (false, false)); + } + + #[test] + fn status_tells_configured_from_running() { + let (on, _) = edit_grub(GRUB, PARAM, true); + assert_eq!(status(&on, "root=/dev/sda quiet"), (true, false)); + assert_eq!(status(GRUB, "quiet mitigations=off"), (false, true)); + assert_eq!(status("", ""), (false, false)); + } + + #[test] + fn the_file_is_backed_up_and_replaced() { + let t = sys::testutil::TempDir::new(); + let path = t.put("etc/default/grub", GRUB); + assert!(apply(t.path(), true).unwrap()); + assert!(fs::read_to_string(&path).unwrap().contains(PARAM)); + assert_eq!( + fs::read_to_string(t.path().join("etc/default/grub.seg-bak")).unwrap(), + GRUB + ); + assert!(!apply(t.path(), true).unwrap(), "already on"); + assert!(apply(t.path(), false).unwrap()); + assert_eq!(fs::read_to_string(&path).unwrap(), GRUB); + assert!(apply(&t.path().join("nowhere"), true).is_err()); + } + + #[test] + fn the_warning_says_what_it_risks_and_how_to_undo() { + assert!(WARNING.contains("Spectre")); + assert!(WARNING.contains("JavaScript in a web page")); + assert!(WARNING.contains("unsafe-boost off")); + assert!(!WARNING.contains('%'), "no invented percentages"); + } + + #[test] + fn arguments() { + let parse = |a: &[&str]| parse_args(&mut lexopt::Parser::from_args(a.iter().copied())); + assert!(matches!( + parse(&["on"]), + Ok(Action::On { understood: false }) + )); + assert!(matches!( + parse(&["on", "--i-understand"]), + Ok(Action::On { understood: true }) + )); + assert!(matches!(parse(&["off"]), Ok(Action::Off))); + assert!(matches!(parse(&[]), Ok(Action::Status))); + assert!(matches!(parse(&["maybe"]), Err(Error::Usage(_)))); + } +} diff --git a/tools/seg/segctl/src/main.rs b/tools/seg/segctl/src/main.rs index e2fae1c..7393917 100644 --- a/tools/seg/segctl/src/main.rs +++ b/tools/seg/segctl/src/main.rs @@ -24,6 +24,7 @@ macro_rules! outln { mod clean; mod doctor; mod error; +mod game; mod info; mod init; mod svc; @@ -46,9 +47,10 @@ Commands: svc list|enable|disable|start|stop [name] services, on whichever init is running clean [--apply] orphans, old cached packages, old logs (dry run by default) - tune light|balanced|performance|apply|status + tune light|balanced|gaming|performance|apply|status memory, CPU and disk settings zram on|off|status compressed swap in memory + game run|check|setup|... play: boost while a game runs, readiness check, drivers doctor look for problems and print their fixes Options: @@ -74,8 +76,8 @@ stop need root; list does not. fn main() -> ExitCode { match run() { - Ok(true) => ExitCode::SUCCESS, - Ok(false) => ExitCode::from(1), + Ok(0) => ExitCode::SUCCESS, + Ok(code) => ExitCode::from(code), Err(e) => { sys::emit_err(format_args!("seg: {e}")); ExitCode::from(e.exit_code()) @@ -83,22 +85,22 @@ fn main() -> ExitCode { } } -/// Returns whether everything was fine (`doctor` reports problems this way). -fn run() -> Result { +/// The exit status: 0, 1 when `doctor` found problems, or the game's own. +fn run() -> Result { use lexopt::prelude::*; let mut parser = lexopt::Parser::from_env(); let command = match parser.next()? { None => { out!("{HELP}"); - return Ok(true); + return Ok(0); } Some(Short('h') | Long("help")) => { out!("{HELP}"); - return Ok(true); + return Ok(0); } Some(Short('V') | Long("version")) => { outln!("seg {}", env!("CARGO_PKG_VERSION")); - return Ok(true); + return Ok(0); } Some(Value(v)) => v.string()?, Some(other) => return Err(other.unexpected().into()), @@ -112,15 +114,16 @@ fn run() -> Result { "clean" => clean::run(clean::parse_args(&mut parser)?)?, "tune" => tune::run(tune::parse_args(&mut parser)?)?, "zram" => zram::run(zram::parse_args(&mut parser)?)?, + "game" => return game::run(&mut parser), "doctor" => { no_more_args(&mut parser, "doctor")?; - return doctor::run(); + return Ok(u8::from(!doctor::run()?)); } other => { return Err(Error::Usage(format!("unknown command `{other}`"))); } } - Ok(true) + Ok(0) } /// Commands without options of their own: `--help` or nothing. diff --git a/tools/seg/segctl/src/tune.rs b/tools/seg/segctl/src/tune.rs index b32fe5e..e42b994 100644 --- a/tools/seg/segctl/src/tune.rs +++ b/tools/seg/segctl/src/tune.rs @@ -1,4 +1,4 @@ -//! `seg tune light|balanced|performance`: memory and writeback sysctls, the +//! `seg tune light|balanced|gaming|performance`: memory and writeback sysctls, the //! CPU governor and the I/O scheduler of every disk, applied now and saved //! for the next boot (/etc/sysctl.d/99-seg.conf, /etc/seg/tune.conf, and the //! `seg-tune` init service that runs `seg tune apply`). @@ -15,12 +15,14 @@ use crate::error::{Context, Error, Result}; use crate::sys::{self, bold}; pub const HELP: &str = "\ -Usage: seg tune [light|balanced|performance|apply|status] [--dry-run] +Usage: seg tune [light|balanced|gaming|performance|apply|status] [--dry-run] light save memory and battery: aggressive reclaim, small dirty limits, the most frugal CPU governor balanced kernel defaults for memory, a dynamic governor - performance keep caches, short writeback bursts, governor `performance` + gaming keep caches, short writeback bursts, low swappiness; the CPU + stays dynamic (seg game run raises it while a game runs) + performance the same, and the governor `performance` all the time apply apply the saved profile (the seg-tune service does this at boot) status show the saved profile and the current values (default) @@ -44,7 +46,12 @@ pub const SYSCTL_CONF: &str = "/etc/sysctl.d/99-seg.conf"; pub enum Mode { Light, Balanced, + /// Everything fast, all the time: the CPU on `performance` too. Performance, + /// For playing: caches kept, writeback in small bursts, low swappiness + /// without zram. The CPU governor stays dynamic: `seg game run` raises it + /// only while a game runs. + Gaming, } impl Mode { @@ -53,6 +60,7 @@ impl Mode { Mode::Light => "light", Mode::Balanced => "balanced", Mode::Performance => "performance", + Mode::Gaming => "gaming", } } } @@ -71,8 +79,9 @@ impl FromStr for Mode { "light" => Ok(Mode::Light), "balanced" => Ok(Mode::Balanced), "performance" => Ok(Mode::Performance), + "gaming" => Ok(Mode::Gaming), other => Err(Error::Usage(format!( - "unknown profile `{other}` (light, balanced or performance)" + "unknown profile `{other}` (light, balanced, gaming or performance)" ))), } } @@ -117,6 +126,14 @@ fn profile(mode: Mode) -> Profile { governors: &["performance"], epp: "performance", }, + Mode::Gaming => Profile { + swappiness: 10, + vfs_cache_pressure: 50, + dirty_ratio: 15, + dirty_background_ratio: 5, + governors: &["schedutil", "ondemand", "powersave"], + epp: "balance_performance", + }, } } @@ -138,7 +155,7 @@ pub fn sysctl_values(mode: Mode, zram_active: bool) -> [(&'static str, u32); 4] pub fn sysctl_conf(mode: Mode, zram_active: bool) -> String { let mut text = format!( - "# Written by `seg tune {mode}`; change it with `sudo seg tune `.\n" + "# Written by `seg tune {mode}`; change it with `sudo seg tune `.\n" ); for (key, value) in sysctl_values(mode, zram_active) { text.push_str(&format!("{key} = {value}\n")); @@ -225,6 +242,33 @@ impl Fs { self.root.join(rel.trim_start_matches('/')) } + /// Apply the `key = value` lines of a sysctl.d file now. + pub fn apply_sysctl_text(&self, text: &str) -> Result { + let mut report = Report::default(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let (key, value) = (key.trim().trim_start_matches('-'), value.trim()); + // A key is dots and word characters, nothing that could leave /proc/sys. + if key.is_empty() + || !key + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + || key.contains("..") + { + report.warnings.push(format!("{key}: not a sysctl name")); + continue; + } + self.set(key, &self.sysctl_path(key), value, false, &mut report)?; + } + Ok(report) + } + pub fn zram_active(&self) -> bool { sys::read_trimmed(self.p("/proc/swaps")) .is_some_and(|t| t.lines().any(|l| l.starts_with("/dev/zram"))) @@ -558,6 +602,7 @@ mod tests { for (text, mode) in [ ("light", Mode::Light), ("balanced", Mode::Balanced), + ("gaming", Mode::Gaming), ("performance", Mode::Performance), ] { assert_eq!(text.parse::().unwrap(), mode); @@ -565,7 +610,10 @@ mod tests { } let e = "turbo".parse::().unwrap_err(); assert!(matches!(e, Error::Usage(_))); - assert!(e.to_string().contains("light, balanced or performance")); + assert!( + e.to_string() + .contains("light, balanced, gaming or performance") + ); } #[test] @@ -579,6 +627,85 @@ mod tests { ); } + #[test] + fn gaming_keeps_the_cpu_dynamic_and_the_caches_warm() { + let g = profile(Mode::Gaming); + // Never the performance governor: that is for the length of a game. + assert!(!g.governors.contains(&"performance")); + assert_eq!( + pick_governor("performance schedutil powersave", g.governors), + Some("schedutil") + ); + // intel_pstate offers only performance and powersave. + assert_eq!( + pick_governor("performance powersave", g.governors), + Some("powersave") + ); + assert_eq!(pick_governor("performance", g.governors), None); + assert_eq!( + sysctl_values(Mode::Gaming, false), + [ + ("vm.swappiness", 10), + ("vm.vfs_cache_pressure", 50), + ("vm.dirty_ratio", 15), + ("vm.dirty_background_ratio", 5), + ] + ); + // With zram, swapping early is right even for gaming. + assert_eq!( + sysctl_values(Mode::Gaming, true)[0], + ("vm.swappiness", ZRAM_SWAPPINESS) + ); + } + + #[test] + fn gaming_saves_like_the_others_and_leaves_the_governor_dynamic() { + let t = fake_system(); + Fs::new(t.path()).apply(Mode::Gaming, true, false).unwrap(); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "10"); + assert_eq!( + read(&t, "sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"), + "schedutil" + ); + assert_eq!( + read(&t, "etc/seg/tune.conf").lines().last(), + Some("mode = gaming") + ); + assert_eq!(Fs::new(t.path()).saved_mode(), Some(Mode::Gaming)); + } + + #[test] + fn a_sysctl_file_is_applied_line_by_line() { + let t = fake_system(); + let report = Fs::new(t.path()) + .apply_sysctl_text( + "# comment + +vm.swappiness = 33 +-vm.dirty_ratio=7 +not a line +vm/../../etc.x = 1 +vm.nonexistent = 1 +", + ) + .unwrap(); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "33"); + assert_eq!(read(&t, "proc/sys/vm/dirty_ratio"), "7"); + assert!( + report + .warnings + .iter() + .any(|w| w.contains("not a sysctl name")), + "{:?}", + report.warnings + ); + assert!( + report.warnings.iter().any(|w| w.contains("vm.nonexistent")), + "{:?}", + report.warnings + ); + } + #[test] fn zram_raises_swappiness_to_the_zram_value() { assert_eq!( diff --git a/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0-DP-1/status b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0-DP-1/status new file mode 100644 index 0000000..da707df --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0-DP-1/status @@ -0,0 +1 @@ +connected diff --git a/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/boot_vga new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/boot_vga @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/device b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/device new file mode 100644 index 0000000..6b8aa72 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/device @@ -0,0 +1 @@ +0x744c diff --git a/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/power_dpm_force_performance_level b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/power_dpm_force_performance_level new file mode 100644 index 0000000..865faf1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/power_dpm_force_performance_level @@ -0,0 +1 @@ +auto diff --git a/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/uevent new file mode 100644 index 0000000..1547d4e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/uevent @@ -0,0 +1,5 @@ +DRIVER=amdgpu +PCI_CLASS=30000 +PCI_ID=1002:744C +PCI_SLOT_NAME=0000:03:00.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/vendor new file mode 100644 index 0000000..7f97d85 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/vendor @@ -0,0 +1 @@ +0x1002 diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/boot_vga new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/boot_vga @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/device b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/device new file mode 100644 index 0000000..614e481 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/device @@ -0,0 +1 @@ +0x46a6 diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/uevent new file mode 100644 index 0000000..d7b3dd8 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/uevent @@ -0,0 +1,5 @@ +DRIVER=i915 +PCI_CLASS=30000 +PCI_ID=8086:46A6 +PCI_SLOT_NAME=0000:00:02.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/vendor new file mode 100644 index 0000000..ce6dc4d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/vendor @@ -0,0 +1 @@ +0x8086 diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/boot_vga new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/boot_vga @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/device b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/device new file mode 100644 index 0000000..a87a297 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/device @@ -0,0 +1 @@ +0x25a2 diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/uevent new file mode 100644 index 0000000..7fb46e5 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/uevent @@ -0,0 +1,5 @@ +DRIVER=nvidia +PCI_CLASS=30000 +PCI_ID=10DE:25A2 +PCI_SLOT_NAME=0000:01:00.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/vendor new file mode 100644 index 0000000..466e5df --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/vendor @@ -0,0 +1 @@ +0x10de diff --git a/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/boot_vga new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/boot_vga @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/device b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/device new file mode 100644 index 0000000..1f178e9 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/device @@ -0,0 +1 @@ +0x56a0 diff --git a/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/uevent new file mode 100644 index 0000000..54e978e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/uevent @@ -0,0 +1,5 @@ +DRIVER=i915 +PCI_CLASS=30000 +PCI_ID=8086:56A0 +PCI_SLOT_NAME=0000:03:00.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/vendor new file mode 100644 index 0000000..ce6dc4d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/vendor @@ -0,0 +1 @@ +0x8086 diff --git a/tools/seg/segctl/tests/fixtures/gpu-none/sys/class/drm/.keep b/tools/seg/segctl/tests/fixtures/gpu-none/sys/class/drm/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/boot_vga new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/boot_vga @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/device b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/device new file mode 100644 index 0000000..d4e74fa --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/device @@ -0,0 +1 @@ +0x2684 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/uevent new file mode 100644 index 0000000..a14f61c --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/uevent @@ -0,0 +1,5 @@ +DRIVER=nouveau +PCI_CLASS=30000 +PCI_ID=10DE:2684 +PCI_SLOT_NAME=0000:01:00.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/vendor new file mode 100644 index 0000000..466e5df --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/vendor @@ -0,0 +1 @@ +0x10de diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/boot_vga new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/boot_vga @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/device b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/device new file mode 100644 index 0000000..d4e74fa --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/device @@ -0,0 +1 @@ +0x2684 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/uevent new file mode 100644 index 0000000..fbe336c --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/uevent @@ -0,0 +1,5 @@ +DRIVER=nvidia +PCI_CLASS=30000 +PCI_ID=10DE:2684 +PCI_SLOT_NAME=0000:01:00.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/vendor new file mode 100644 index 0000000..466e5df --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/vendor @@ -0,0 +1 @@ +0x10de diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/boot_vga new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/boot_vga @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/device b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/device new file mode 100644 index 0000000..47b6ccd --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/device @@ -0,0 +1 @@ +0x1b80 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/uevent new file mode 100644 index 0000000..4234a72 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/uevent @@ -0,0 +1,5 @@ +DRIVER=nouveau +PCI_CLASS=30000 +PCI_ID=10DE:1B80 +PCI_SLOT_NAME=0000:01:00.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/vendor new file mode 100644 index 0000000..466e5df --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/vendor @@ -0,0 +1 @@ +0x10de diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/proc/cpuinfo b/tools/seg/segctl/tests/fixtures/intel-12700/proc/cpuinfo new file mode 100644 index 0000000..f44eb79 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/proc/cpuinfo @@ -0,0 +1,11 @@ +processor : 0 +vendor_id : GenuineIntel +cpu family : 25 +model : 97 +model name : 12th Gen Intel(R) Core(TM) i7-12700 +stepping : 2 +cache size : 1024 KB + +processor : 1 +vendor_id : GenuineIntel +model name : 12th Gen Intel(R) Core(TM) i7-12700 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/level b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/size b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/type b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/level b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list new file mode 100644 index 0000000..60f488a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-19 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/size b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/size new file mode 100644 index 0000000..9ecd9a0 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/size @@ -0,0 +1 @@ +25600K diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/type b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/level b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/shared_cpu_list new file mode 100644 index 0000000..b8626c4 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +4 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/size b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/type b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/level b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/shared_cpu_list new file mode 100644 index 0000000..60f488a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-19 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/size b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/size new file mode 100644 index 0000000..9ecd9a0 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/size @@ -0,0 +1 @@ +25600K diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/type b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/proc/cpuinfo b/tools/seg/segctl/tests/fixtures/ryzen-5950x/proc/cpuinfo new file mode 100644 index 0000000..70e077c --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/proc/cpuinfo @@ -0,0 +1,11 @@ +processor : 0 +vendor_id : AuthenticAMD +cpu family : 25 +model : 97 +model name : AMD Ryzen 9 5950X 16-Core Processor +stepping : 2 +cache size : 1024 KB + +processor : 1 +vendor_id : AuthenticAMD +model name : AMD Ryzen 9 5950X 16-Core Processor diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list new file mode 100644 index 0000000..20128cf --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-7,16-23 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/size new file mode 100644 index 0000000..f6f9d5d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/size @@ -0,0 +1 @@ +32768K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list new file mode 100644 index 0000000..45a4fb7 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +8 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list new file mode 100644 index 0000000..277d78d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +8-15,24-31 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/size new file mode 100644 index 0000000..f6f9d5d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/size @@ -0,0 +1 @@ +32768K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/proc/cpuinfo b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/proc/cpuinfo new file mode 100644 index 0000000..5ec6c0c --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/proc/cpuinfo @@ -0,0 +1,11 @@ +processor : 0 +vendor_id : AuthenticAMD +cpu family : 25 +model : 97 +model name : AMD Ryzen 7 7800X3D 8-Core Processor +stepping : 2 +cache size : 1024 KB + +processor : 1 +vendor_id : AuthenticAMD +model name : AMD Ryzen 7 7800X3D 8-Core Processor diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list new file mode 100644 index 0000000..580a385 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-15 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/size new file mode 100644 index 0000000..f25a4b1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/size @@ -0,0 +1 @@ +98304K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list new file mode 100644 index 0000000..45a4fb7 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +8 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list new file mode 100644 index 0000000..580a385 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-15 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/size new file mode 100644 index 0000000..f25a4b1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/size @@ -0,0 +1 @@ +98304K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/proc/cpuinfo b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/proc/cpuinfo new file mode 100644 index 0000000..dfbf2b6 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/proc/cpuinfo @@ -0,0 +1,11 @@ +processor : 0 +vendor_id : AuthenticAMD +cpu family : 25 +model : 97 +model name : AMD Ryzen 9 7950X3D 16-Core Processor +stepping : 2 +cache size : 1024 KB + +processor : 1 +vendor_id : AuthenticAMD +model name : AMD Ryzen 9 7950X3D 16-Core Processor diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list new file mode 100644 index 0000000..20128cf --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-7,16-23 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/size new file mode 100644 index 0000000..f25a4b1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/size @@ -0,0 +1 @@ +98304K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/shared_cpu_list new file mode 100644 index 0000000..b6a7d89 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +16 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/shared_cpu_list new file mode 100644 index 0000000..20128cf --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-7,16-23 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/size new file mode 100644 index 0000000..f25a4b1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/size @@ -0,0 +1 @@ +98304K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/shared_cpu_list new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +24 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/shared_cpu_list new file mode 100644 index 0000000..277d78d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +8-15,24-31 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/size new file mode 100644 index 0000000..f6f9d5d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/size @@ -0,0 +1 @@ +32768K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list new file mode 100644 index 0000000..45a4fb7 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +8 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list new file mode 100644 index 0000000..277d78d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +8-15,24-31 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/size new file mode 100644 index 0000000..f6f9d5d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/size @@ -0,0 +1 @@ +32768K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/proc/cpuinfo b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/proc/cpuinfo new file mode 100644 index 0000000..cf47cb1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/proc/cpuinfo @@ -0,0 +1,11 @@ +processor : 0 +vendor_id : AuthenticAMD +cpu family : 25 +model : 97 +model name : AMD Ryzen 9 9950X3D 16-Core Processor +stepping : 2 +cache size : 1024 KB + +processor : 1 +vendor_id : AuthenticAMD +model name : AMD Ryzen 9 9950X3D 16-Core Processor diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list new file mode 100644 index 0000000..20128cf --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-7,16-23 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/size new file mode 100644 index 0000000..f6f9d5d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/size @@ -0,0 +1 @@ +32768K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list new file mode 100644 index 0000000..45a4fb7 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +8 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list new file mode 100644 index 0000000..277d78d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +8-15,24-31 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/size new file mode 100644 index 0000000..f25a4b1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/size @@ -0,0 +1 @@ +98304K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/vm/proc/cpuinfo b/tools/seg/segctl/tests/fixtures/vm/proc/cpuinfo new file mode 100644 index 0000000..dff7565 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/vm/proc/cpuinfo @@ -0,0 +1,3 @@ +processor : 0 +vendor_id : AuthenticAMD +model name : QEMU Virtual CPU version 2.5+ diff --git a/tools/seg/segctl/tests/fixtures/vm/sys/devices/system/cpu/cpu0/online b/tools/seg/segctl/tests/fixtures/vm/sys/devices/system/cpu/cpu0/online new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/vm/sys/devices/system/cpu/cpu0/online @@ -0,0 +1 @@ +1 From 7440b18b0c260e393d7cddc29a0cc346315549ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:33:50 +0000 Subject: [PATCH 14/23] feat(seg-game): package the game services, sudoers rule and presets segctl 0.1.0-2 installs the seg-game service for OpenRC, runit, dinit and s6, /etc/sudoers.d/seg-game (the one root helper seg game run calls, for the gamemode group only) and the MangoHud and low-latency PipeWire presets under /usr/share/seg, all off until asked. The install script creates the group (a chroot-safe getent/groupadd) and enables seg-game on the init that is installed; an upgrade from 0.1.0-1 gets the new service, nothing else is re-enabled. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- pkg/segctl/PKGBUILD | 33 +++++++++++++++++++++++++-------- pkg/segctl/segctl.install | 25 ++++++++++++++++++++----- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/pkg/segctl/PKGBUILD b/pkg/segctl/PKGBUILD index 63dd517..d974cd8 100644 --- a/pkg/segctl/PKGBUILD +++ b/pkg/segctl/PKGBUILD @@ -7,8 +7,8 @@ pkgname=segctl pkgver=0.1.0 -pkgrel=1 -pkgdesc="seg: services, cleaning, tuning, zram swap and health checks for SegLinux, on OpenRC, runit, dinit and s6" +pkgrel=2 +pkgdesc="seg: services, cleaning, tuning, zram swap, game mode and health checks for SegLinux, on OpenRC, runit, dinit and s6" arch=('x86_64') url="https://github.com/SegMind25/SegLinux" # No license has been chosen for the SegLinux repository yet. @@ -19,7 +19,12 @@ depends=('gcc-libs' 'glibc' 'util-linux' 'kmod' 'coreutils') makedepends=('cargo') optdepends=( 'cronie: the weekly fstrim job (/etc/cron.d/seg-fstrim)' - 'pacman: seg clean and seg doctor' + 'pacman: seg clean, seg doctor and seg game setup' + 'sudo: seg game run raises the CPU governor through it (the gamemode group)' + 'vulkan-tools: seg game check asks vulkaninfo' + 'mangohud: per-game overlay (mangohud = true in games.toml)' + 'gamescope: per-game gamescope arguments' + 'pciutils: names of the graphics cards in seg game check and setup' ) # The profile the user chose, and the sysctl values it wrote. backup=( @@ -56,15 +61,27 @@ package() { install -Dm755 target/release/seg "$pkgdir/usr/bin/seg" - # The seg-tune and seg-zram services for each init the project builds - # (they do nothing on one that is not installed). - install -Dm755 -t "$pkgdir/etc/init.d" dist/openrc/seg-tune dist/openrc/seg-zram + # The seg-tune, seg-zram and seg-game services for each init (they do + # nothing on one that is not installed). + install -Dm755 -t "$pkgdir/etc/init.d" dist/openrc/seg-tune dist/openrc/seg-zram dist/openrc/seg-game local svc - for svc in seg-tune seg-zram; do + for svc in seg-tune seg-zram seg-game; do install -Dm755 "dist/runit/$svc/run" "$pkgdir/etc/runit/sv/$svc/run" done install -Dm755 dist/runit/seg-zram/finish "$pkgdir/etc/runit/sv/seg-zram/finish" - install -Dm644 -t "$pkgdir/etc/dinit.d" dist/dinit/seg-tune dist/dinit/seg-zram + install -Dm644 -t "$pkgdir/etc/dinit.d" dist/dinit/seg-tune dist/dinit/seg-zram dist/dinit/seg-game + install -Dm644 -t "$pkgdir/etc/s6/sv/seg-game" dist/s6/seg-game/type dist/s6/seg-game/up + + # seg game run: the root helper it calls through sudo, for the gamemode + # group only (the folder keeps the mode the sudo package gives it). + install -dm750 "$pkgdir/etc/sudoers.d" + install -m440 dist/sudoers-seg-game "$pkgdir/etc/sudoers.d/seg-game" + + # Presets, off until asked for: low-latency PipeWire (seg game audio on) + # and a MangoHud overlay for the game profiles. + install -Dm644 -t "$pkgdir/usr/share/seg/pipewire/pipewire.conf.d" dist/pipewire/pipewire.conf.d/99-seg-lowlatency.conf + install -Dm644 -t "$pkgdir/usr/share/seg/pipewire/pipewire-pulse.conf.d" dist/pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf + install -Dm644 dist/mangohud-gaming.conf "$pkgdir/usr/share/seg/mangohud-gaming.conf" # Defaults: the balanced profile. install -Dm644 dist/tune.conf "$pkgdir/etc/seg/tune.conf" diff --git a/pkg/segctl/segctl.install b/pkg/segctl/segctl.install index 55c5e66..0783b82 100644 --- a/pkg/segctl/segctl.install +++ b/pkg/segctl/segctl.install @@ -1,25 +1,40 @@ -# Start the zram swap and the tuning profile at every boot. +# The gamemode group (SegLinux Setup puts every user in it, and the package +# gamemode uses the same name): seg game run may raise the CPU governor for +# its members, through /etc/sudoers.d/seg-game. +make_group() { + getent group gamemode >/dev/null 2>&1 || groupadd -r gamemode >/dev/null 2>&1 || true +} + +# Start the zram swap, the gaming settings and the tuning profile at every boot. # # This may run in a chroot (the image build) whose init is not the host's, so # the init is chosen by what is installed here (--offline), not by PID 1. enable_services() { command -v seg >/dev/null 2>&1 || return 0 local svc - for svc in seg-zram seg-tune; do + for svc in "$@"; do seg svc --offline enable "$svc" >/dev/null 2>&1 || true done } post_install() { - enable_services + make_group + enable_services seg-zram seg-game seg-tune } -# Not on upgrade: someone who disabled a service keeps it disabled. +# An upgrade from before seg game gets that one service and the group; the +# others are left as the user has them (someone who disabled one keeps it so). +post_upgrade() { + make_group + if [ "$(vercmp "$2" 0.1.0-2)" -lt 0 ]; then + enable_services seg-game + fi +} pre_remove() { command -v seg >/dev/null 2>&1 || return 0 local svc - for svc in seg-tune seg-zram; do + for svc in seg-tune seg-game seg-zram; do seg svc --offline disable "$svc" >/dev/null 2>&1 || true done } From fa1a13907275c0245d57a43324794d7a5ad36f9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:33:50 +0000 Subject: [PATCH 15/23] perf: gaming sysctl, open-file limit, shader cache sizes and NVIDIA modeset - vm.compaction_proactiveness = 0: no background memory defragmentation while a game allocates (what SteamOS sets); on-demand compaction stays. - nofile hard limit 524288 -> 1048576 (the kernel's fs.nr_open maximum) for Wine's esync/fsync; members of gamemode may use nice -10 for seg game run. - MESA_SHADER_CACHE_MAX_SIZE 4G -> 10G and the NVIDIA cache set to 10 GiB without the cleanup that deletes it when full (profile.d, so every init; a value set by the user wins). - nvidia_drm modeset=1 fbdev=1 (needed by Wayland compositors); does nothing unless the driver is installed. - vm.swappiness is deliberately not in this file: 180 with zram is Setup's, 10 without it is seg tune gaming's. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- data/system/limits-esync.conf | 10 ++++++++-- data/system/modprobe-nvidia.conf | 4 ++++ data/system/profile-gaming.sh | 20 ++++++++++++++------ data/system/sysctl-gaming.conf | 11 +++++++++++ packaging/seglinux/PKGBUILD | 3 ++- 5 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 data/system/modprobe-nvidia.conf diff --git a/data/system/limits-esync.conf b/data/system/limits-esync.conf index 6690f7b..6f5ab56 100644 --- a/data/system/limits-esync.conf +++ b/data/system/limits-esync.conf @@ -1,4 +1,10 @@ # esync (Wine-staging, Proton) keeps one file descriptor per Windows # synchronisation object. Raise the hard limit; Wine raises its own soft -# limit up to it. -* hard nofile 524288 +# limit up to it. 1048576 is the most the kernel allows (fs.nr_open) and what +# Steam Deck-class setups use, for games with very many objects. +* hard nofile 1048576 + +# seg game run raises the game's CPU priority above normal (nice -4). Normal +# users may not, unless a limit says so: members of the gamemode group, which +# SegLinux Setup puts every user in, may go down to -10. +@gamemode - nice -10 diff --git a/data/system/modprobe-nvidia.conf b/data/system/modprobe-nvidia.conf new file mode 100644 index 0000000..270cbef --- /dev/null +++ b/data/system/modprobe-nvidia.conf @@ -0,0 +1,4 @@ +# NVIDIA's DRM driver: kernel mode setting (needed by Wayland compositors and +# for a proper VT switch) and the framebuffer console on top of it. Without +# the nvidia driver installed this file does nothing. +options nvidia_drm modeset=1 fbdev=1 diff --git a/data/system/profile-gaming.sh b/data/system/profile-gaming.sh index 644c780..f50ffce 100644 --- a/data/system/profile-gaming.sh +++ b/data/system/profile-gaming.sh @@ -1,6 +1,14 @@ -# SegLinux: room for the shaders of many games in Mesa's disk cache -# (~/.cache/mesa_shader_cache). DXVK 2 and VKD3D-Proton keep their compiled -# shaders there; with Mesa's 1 GB default, installing several games evicts -# them and games stutter while recompiling. Set your own value to override. -: "${MESA_SHADER_CACHE_MAX_SIZE:=4G}" -export MESA_SHADER_CACHE_MAX_SIZE +# SegLinux: room for the shader caches of many games. +# +# Mesa's disk cache (~/.cache/mesa_shader_cache): DXVK 2 and VKD3D-Proton keep +# their compiled shaders there; with Mesa's 1 GB default, installing several +# games evicts them and games stutter while recompiling. +# NVIDIA's driver has its own cache (~/.nv/GLCache, the compute cache and the +# shader cache of Vulkan); its size is in bytes, and by default the driver +# deletes the cache once it is full, so the cleanup is switched off. +# Set your own values to override: they are only set here when not set yet. +# (/etc/environment.d is for systemd; this works on every init.) +: "${MESA_SHADER_CACHE_MAX_SIZE:=10G}" +: "${__GL_SHADER_DISK_CACHE_SIZE:=10737418240}" +: "${__GL_SHADER_DISK_CACHE_SKIP_CLEANUP:=1}" +export MESA_SHADER_CACHE_MAX_SIZE __GL_SHADER_DISK_CACHE_SIZE __GL_SHADER_DISK_CACHE_SKIP_CLEANUP diff --git a/data/system/sysctl-gaming.conf b/data/system/sysctl-gaming.conf index 11b48f0..4f51c5b 100644 --- a/data/system/sysctl-gaming.conf +++ b/data/system/sysctl-gaming.conf @@ -16,3 +16,14 @@ kernel.split_lock_mitigate = 0 # Game launchers and Steam watch many files. fs.inotify.max_user_watches = 524288 + +# The kernel defragments memory in the background to make huge pages +# available; while a game allocates and frees a lot, that work shows up as +# stutter. 0 turns the background work off (the default is 20); a program +# that needs a contiguous block still gets it by compacting on demand. This is +# what SteamOS and the gaming kernels set. +vm.compaction_proactiveness = 0 + +# vm.swappiness is not set here on purpose. With zram swap (the default) a +# high value is right and Setup writes it (90-seglinux-zram.conf); without +# it a low one is, which `seg tune gaming` sets (10). diff --git a/packaging/seglinux/PKGBUILD b/packaging/seglinux/PKGBUILD index 772cb95..7b07001 100644 --- a/packaging/seglinux/PKGBUILD +++ b/packaging/seglinux/PKGBUILD @@ -6,7 +6,7 @@ pkgname=seglinux pkgver=0.1.0 -pkgrel=9 +pkgrel=10 pkgdesc="SegLinux: lightweight Rust desktop, Windows program and game launcher, graphical installer and gaming tuning" arch=('x86_64') url="https://github.com/SegMind25/SegLinux" @@ -175,6 +175,7 @@ package() { install -Dm644 "$data/system/udev-ioschedulers.rules" "$pkgdir/usr/lib/udev/rules.d/60-seglinux-ioschedulers.rules" install -Dm644 "$data/system/modules-ddcutil.conf" "$pkgdir/etc/modules-load.d/ddcutil.conf" install -Dm644 "$data/system/modprobe-nowatchdog.conf" "$pkgdir/usr/lib/modprobe.d/seglinux-nowatchdog.conf" + install -Dm644 "$data/system/modprobe-nvidia.conf" "$pkgdir/usr/lib/modprobe.d/seglinux-nvidia.conf" install -Dm644 "$data/system/gamemode.ini" "$pkgdir/etc/gamemode.ini" install -Dm644 "$data/system/profile-gaming.sh" "$pkgdir/etc/profile.d/seglinux-gaming.sh" From 6fc01a4d1b8c51a10e0db6918dd86430f0a0a668 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:33:51 +0000 Subject: [PATCH 16/23] perf: wine-staging and lib32-mangohud in the full edition wine-staging provides wine and adds the Staging patches (esync/fsync and NTSYNC support, game fixes); lib32-mangohud covers 32-bit games. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- iso/iso-profiles/seglinux/profile.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iso/iso-profiles/seglinux/profile.yaml b/iso/iso-profiles/seglinux/profile.yaml index 4405251..8420b1f 100644 --- a/iso/iso-profiles/seglinux/profile.yaml +++ b/iso/iso-profiles/seglinux/profile.yaml @@ -127,13 +127,14 @@ rootfs: - ttf-carlito # Running Windows programs and games - - wine + - wine-staging # Wine with the Staging patches (esync/fsync/NTSYNC, game fixes); provides wine - winetricks - steam # Steam and Proton - lutris # GOG, Epic, Battle.net and other launchers - gamemode # CPU/GPU tuning while a game runs - lib32-gamemode - mangohud # FPS and frame-time overlay (64-bit games) + - lib32-mangohud # the same for 32-bit games - gamescope # gaming compositor, Gaming Mode session - dosbox - mesa From 66a8cfd311cf84bc3fd959c394b4671378087cf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:33:51 +0000 Subject: [PATCH 17/23] feat(setup): a fallback kernel, amd_pstate=active, THP madvise and seg-game - Install the other kernel (linux beside linux-zen and the reverse) as a boot menu entry to fall back on, when online; the kernel that runs is never removed. performance.fallback_kernel = false skips it. - Kernel command line: transparent_hugepage=madvise on every kernel and amd_pstate=active on AuthenticAMD; a parameter of the same name in the answer file replaces the default. - seg-game joins the default services, so the gaming sysctls and /dev/ntsync permissions apply on whichever init the install uses. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- crates/seglinux-installer/src/model.rs | 1 + .../seglinux-setup/autounattend.example.toml | 3 +- crates/seglinux-setup/src/answer.rs | 11 +- crates/seglinux-setup/src/facts.rs | 22 +++ crates/seglinux-setup/src/plan.rs | 179 +++++++++++++++++- crates/seglinux-setup/src/tui.rs | 1 + docs/unattended-install.md | 3 +- 7 files changed, 212 insertions(+), 8 deletions(-) diff --git a/crates/seglinux-installer/src/model.rs b/crates/seglinux-installer/src/model.rs index 283f8cd..b1b869d 100644 --- a/crates/seglinux-installer/src/model.rs +++ b/crates/seglinux-installer/src/model.rs @@ -201,6 +201,7 @@ pub fn demo_facts() -> Facts { memory: 8 * GIB, gpus: vec![], kernel: Some("linux-zen".into()), + cpu_vendor: None, timezones: Some(set(&[ "UTC", "Africa/Tunis", diff --git a/crates/seglinux-setup/autounattend.example.toml b/crates/seglinux-setup/autounattend.example.toml index 5b19cbb..69a6574 100644 --- a/crates/seglinux-setup/autounattend.example.toml +++ b/crates/seglinux-setup/autounattend.example.toml @@ -62,7 +62,8 @@ os_prober = true # list Windows in the boot menu # Gaming and boot tuning. [performance] parallel_boot = true # OpenRC: start services in parallel -kernel_parameters = [] # added to: loglevel=3 quiet nowatchdog nmi_watchdog=0 +kernel_parameters = [] # added to: loglevel=3 quiet nowatchdog nmi_watchdog=0 transparent_hugepage=madvise +fallback_kernel = true # also install the other kernel (linux beside linux-zen), a boot menu entry to fall back on # GPU drivers. "auto" installs NVIDIA's driver for GeForce GTX 16/RTX cards # when the network is up; "never" keeps the open-source driver. diff --git a/crates/seglinux-setup/src/answer.rs b/crates/seglinux-setup/src/answer.rs index 3b002a2..fdc2b12 100644 --- a/crates/seglinux-setup/src/answer.rs +++ b/crates/seglinux-setup/src/answer.rs @@ -266,8 +266,14 @@ pub struct PerformanceSection { /// OpenRC: start services in parallel (`rc_parallel`) for a faster boot. pub parallel_boot: bool, /// Kernel parameters added to SegLinux's defaults - /// (`loglevel=3 quiet nowatchdog nmi_watchdog=0`). + /// (`loglevel=3 quiet nowatchdog nmi_watchdog=0 transparent_hugepage=madvise`, + /// `zswap.enabled=0` with zram, `amd_pstate=active` on AMD processors). + /// A parameter given here replaces the default of the same name. pub kernel_parameters: Vec, + /// Install the other kernel (`linux` next to `linux-zen`, the reverse for + /// the stock kernel) as a boot menu entry to fall back on, when the + /// network is reachable. The kernel that is installed is never removed. + pub fallback_kernel: bool, } impl Default for PerformanceSection { @@ -275,6 +281,7 @@ impl Default for PerformanceSection { Self { parallel_boot: true, kernel_parameters: Vec::new(), + fallback_kernel: true, } } } @@ -342,6 +349,8 @@ pub const DEFAULT_SERVICES: &[&str] = &[ // From the segctl package: zram swap and the saved `seg tune` profile. "seg-zram", "seg-tune", + // The gaming sysctls and /dev/ntsync permissions, on every init. + "seg-game", ]; /// Groups every user joins (missing ones are skipped). diff --git a/crates/seglinux-setup/src/facts.rs b/crates/seglinux-setup/src/facts.rs index a89e925..9eb18d2 100644 --- a/crates/seglinux-setup/src/facts.rs +++ b/crates/seglinux-setup/src/facts.rs @@ -112,6 +112,8 @@ pub struct Facts { /// Package name of the running kernel (`linux-zen`): the new system gets /// the same one, and its headers when a driver has to be built. pub kernel: Option, + /// `vendor_id` of the processor in /proc/cpuinfo (`AuthenticAMD`). + pub cpu_vendor: Option, /// Known values for validation and pick lists; `None` = not checked. pub timezones: Option>, pub locales: Option>, @@ -144,6 +146,9 @@ impl Facts { memory: memory_bytes().unwrap_or(0), gpus: list_gpus(Path::new("/sys/bus/pci/devices")), kernel: running_kernel(), + cpu_vendor: fs::read_to_string("/proc/cpuinfo") + .ok() + .and_then(|t| parse_cpu_vendor(&t)), timezones: Some(list_timezones(Path::new("/usr/share/zoneinfo"))) .filter(|s| !s.is_empty()), locales: fs::read_to_string("/usr/share/i18n/SUPPORTED") @@ -304,6 +309,14 @@ pub fn running_kernel() -> Option { parse_pkgbase(&pkgbase) } +/// `vendor_id : AuthenticAMD` of the first processor. +pub fn parse_cpu_vendor(text: &str) -> Option { + text.lines().find_map(|l| { + let (key, value) = l.split_once(':')?; + (key.trim() == "vendor_id").then(|| value.trim().to_string()) + }) +} + pub fn parse_pkgbase(text: &str) -> Option { let name = text.lines().next()?.trim(); let valid = !name.is_empty() @@ -509,6 +522,15 @@ mod tests { assert_eq!(parse_meminfo_total("nope"), None); } + #[test] + fn cpu_vendor_from_cpuinfo() { + assert_eq!( + parse_cpu_vendor("processor\t: 0\nvendor_id\t: AuthenticAMD\nmodel name\t: x\n"), + Some("AuthenticAMD".into()) + ); + assert_eq!(parse_cpu_vendor("model name : x\n"), None); + } + #[test] fn kernel_package_from_pkgbase() { assert_eq!(parse_pkgbase("linux-zen\n"), Some("linux-zen".into())); diff --git a/crates/seglinux-setup/src/plan.rs b/crates/seglinux-setup/src/plan.rs index 1f1295d..037435f 100644 --- a/crates/seglinux-setup/src/plan.rs +++ b/crates/seglinux-setup/src/plan.rs @@ -832,6 +832,22 @@ pub fn build( )], }); } + // A second kernel to fall back on: the boot menu lists every installed + // kernel (the newest, linux-zen, stays the default). Optional, like every + // step that needs the network: without it the system has the one kernel. + if answer.performance.fallback_kernel + && facts.network + && let Some(other) = fallback_kernel(facts.kernel.as_deref()) + { + sp.push(Step { + description: format!( + "Installing the {other} kernel as a fallback boot entry (needs a network connection)" + ), + actions: vec![Action::chroot_optional( + Cmd::new("pacman").args(["-Syu", "--noconfirm", "--needed", other]), + )], + }); + } sp.push(Step { description: "Building the boot image (initramfs)".into(), actions: vec![Action::chroot(Cmd::new("mkinitcpio").arg("-P"))], @@ -860,7 +876,10 @@ pub fn build( grub.push(Action::set_key( "/etc/default/grub", "GRUB_CMDLINE_LINUX_DEFAULT", - format!("\"{}\"", kernel_parameters(answer, &layout).join(" ")), + format!( + "\"{}\"", + kernel_parameters(answer, &layout, facts).join(" ") + ), )); let efi_install = |extra: &[&str]| { Cmd::new("grub-install") @@ -1060,7 +1079,7 @@ pub fn build( /// soft-lockup watchdogs (less background work and fewer interrupts while /// gaming), zswap off when zram provides the swap, then the answer file's /// own parameters. -pub fn kernel_parameters(answer: &Answer, layout: &Layout) -> Vec { +pub fn kernel_parameters(answer: &Answer, layout: &Layout, facts: &Facts) -> Vec { let mut params: Vec = ["loglevel=3", "quiet", "nowatchdog", "nmi_watchdog=0"] .iter() .map(|s| s.to_string()) @@ -1068,6 +1087,30 @@ pub fn kernel_parameters(answer: &Answer, layout: &Layout) -> Vec { if layout.zram.is_some() { params.push("zswap.enabled=0".into()); } + // Hugepages only for programs that ask for them (games, databases): the + // kernel default of `always` can stall on compaction. Arch's kernels + // already say madvise; this makes it so on every kernel. + // AMD processors from Zen 2 on do better with amd-pstate in active mode + // (the processor picks its frequency, guided by the energy hints that + // `seg game run` sets). Kernels or processors without it ignore this. + let mut defaults = vec!["transparent_hugepage=madvise"]; + if facts.cpu_vendor.as_deref() == Some("AuthenticAMD") { + defaults.push("amd_pstate=active"); + } + // The answer file's own parameters win: one with the same name replaces + // the default, the rest are added. + let key = |p: &str| p.split('=').next().unwrap_or(p).to_string(); + let own: Vec = answer + .performance + .kernel_parameters + .iter() + .map(|p| key(p)) + .collect(); + for d in defaults { + if !own.contains(&key(d)) { + params.push(d.into()); + } + } for p in &answer.performance.kernel_parameters { if !params.contains(p) { params.push(p.clone()); @@ -1076,6 +1119,17 @@ pub fn kernel_parameters(answer: &Answer, layout: &Layout) -> Vec { params } +/// The kernel to install next to the one that runs, so that the boot menu +/// has a second entry: `linux` beside `linux-zen` or `linux-lts`, `linux-zen` +/// beside the stock kernel. The one that runs is never touched. +pub fn fallback_kernel(running: Option<&str>) -> Option<&'static str> { + match running { + Some("linux-zen") | Some("linux-lts") | Some("linux-hardened") => Some("linux"), + Some("linux") => Some("linux-zen"), + _ => None, + } +} + fn run_command_step(c: &crate::answer::RunCommand) -> Step { Step { description: c @@ -1148,6 +1202,7 @@ mod tests { memory: 4 * GIB, gpus: vec![], kernel: Some("linux-zen".into()), + cpu_vendor: None, timezones: None, locales: None, keyboards: None, @@ -1200,7 +1255,7 @@ mod tests { assert!(text.contains("| /dev/zram0 none swap defaults,discard,pri=100 0 0")); assert!(text.contains("| vm.swappiness = 180")); assert!(text.contains( - "set GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog nmi_watchdog=0 zswap.enabled=0\" in /etc/default/grub" + "set GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog nmi_watchdog=0 zswap.enabled=0 transparent_hugepage=madvise\" in /etc/default/grub" )); assert!(text.contains("set rc_parallel=\"YES\" in /etc/rc.conf")); // The sign-in screen preselects the SegLinux desktop and the user. @@ -1285,7 +1340,7 @@ mod tests { assert!(!text.contains("/dev/zram0"), "{text}"); assert!( text.contains( - "GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog nmi_watchdog=0\"" + "GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog nmi_watchdog=0 transparent_hugepage=madvise\"" ) ); } @@ -1359,13 +1414,127 @@ mod tests { assert!(!full.contains("restore-trimmed-files")); } + fn cmdline(a: &Answer, f: &Facts) -> String { + let text = build_plan(a, f).unwrap().to_string(); + let start = text.find("set GRUB_CMDLINE_LINUX_DEFAULT=\"").unwrap(); + let rest = &text[start + "set GRUB_CMDLINE_LINUX_DEFAULT=\"".len()..]; + rest[..rest.find('"').unwrap()].to_string() + } + + #[test] + fn amd_processors_get_amd_pstate_active_and_others_do_not() { + let mut f = facts(); + f.cpu_vendor = Some("AuthenticAMD".into()); + let c = cmdline(&answer(""), &f); + assert!( + c.ends_with("transparent_hugepage=madvise amd_pstate=active"), + "{c}" + ); + f.cpu_vendor = Some("GenuineIntel".into()); + assert!(!cmdline(&answer(""), &f).contains("amd_pstate")); + f.cpu_vendor = None; + assert!(!cmdline(&answer(""), &f).contains("amd_pstate")); + } + + #[test] + fn an_answer_file_replaces_a_default_of_the_same_name() { + let mut f = facts(); + f.cpu_vendor = Some("AuthenticAMD".into()); + let a = answer( + "[performance]\nkernel_parameters = [\"amd_pstate=passive\", \"transparent_hugepage=always\"]\n", + ); + let c = cmdline(&a, &f); + assert!( + c.contains("amd_pstate=passive") && !c.contains("amd_pstate=active"), + "{c}" + ); + assert!( + c.contains("transparent_hugepage=always") + && !c.contains("transparent_hugepage=madvise"), + "{c}" + ); + // Mitigations are never touched by Setup. + assert!(!c.contains("mitigations"), "{c}"); + } + + #[test] + fn the_other_kernel_is_chosen_to_fall_back_on() { + assert_eq!(fallback_kernel(Some("linux-zen")), Some("linux")); + assert_eq!(fallback_kernel(Some("linux-lts")), Some("linux")); + assert_eq!(fallback_kernel(Some("linux")), Some("linux-zen")); + assert_eq!(fallback_kernel(Some("linux-cachyos")), None); + assert_eq!(fallback_kernel(None), None); + } + + #[test] + fn a_fallback_kernel_is_installed_before_the_boot_image_is_built() { + let f = facts(); + let text = build_plan(&answer(""), &f).unwrap().to_string(); + assert!( + text.contains( + "Installing the linux kernel as a fallback boot entry (needs a network connection)" + ), + "{text}" + ); + let install = text + .find("pacman -Syu --noconfirm --needed linux # may fail") + .unwrap(); + assert!(install < text.find("mkinitcpio -P").unwrap()); + assert!(install < text.find("grub-mkconfig").unwrap()); + + // The stock kernel gets zen beside it. + let mut stock = facts(); + stock.kernel = Some("linux".into()); + assert!( + build_plan(&answer(""), &stock) + .unwrap() + .to_string() + .contains("--needed linux-zen # may fail") + ); + + // Not without a network, when switched off, or for a kernel we do not know. + let mut offline = facts(); + offline.network = false; + assert!( + !build_plan(&answer(""), &offline) + .unwrap() + .to_string() + .contains("fallback boot entry") + ); + let off = answer("[performance]\nfallback_kernel = false\n"); + assert!( + !build_plan(&off, &f) + .unwrap() + .to_string() + .contains("fallback boot entry") + ); + let mut unknown = facts(); + unknown.kernel = None; + assert!( + !build_plan(&answer(""), &unknown) + .unwrap() + .to_string() + .contains("fallback boot entry") + ); + } + + #[test] + fn nothing_removes_a_kernel() { + let text = build_plan(&answer(""), &facts()).unwrap().to_string(); + assert!(!text.contains("pacman -R"), "{text}"); + } + #[test] fn extra_kernel_parameters() { let a = answer( "[performance]\nparallel_boot = false\nkernel_parameters = [\"amd_pstate=active\", \"quiet\"]\n", ); let text = build_plan(&a, &facts()).unwrap().to_string(); - assert!(text.contains("nmi_watchdog=0 zswap.enabled=0 amd_pstate=active\"")); + // The answer file's own amd_pstate is the one that counts, once. + assert!(text.contains( + "nmi_watchdog=0 zswap.enabled=0 transparent_hugepage=madvise amd_pstate=active\"" + )); + assert_eq!(text.matches("amd_pstate").count(), 1, "{text}"); assert!(!text.contains("rc_parallel")); } diff --git a/crates/seglinux-setup/src/tui.rs b/crates/seglinux-setup/src/tui.rs index e14be2a..1d82eb4 100644 --- a/crates/seglinux-setup/src/tui.rs +++ b/crates/seglinux-setup/src/tui.rs @@ -819,6 +819,7 @@ mod tests { memory: 8 * GIB, gpus: vec![], kernel: Some("linux-zen".into()), + cpu_vendor: None, timezones: Some( ["UTC", "Europe/Paris", "Europe/Berlin"] .iter() diff --git a/docs/unattended-install.md b/docs/unattended-install.md index cea4e0d..eb837bf 100644 --- a/docs/unattended-install.md +++ b/docs/unattended-install.md @@ -82,7 +82,8 @@ start it by hand: `sudo seglinux-setup install --unattend`. | `[bootloader] removable` | `true` | | Also install `\EFI\BOOT\BOOTX64.EFI` | | `[bootloader] os_prober` | `true` | | Add Windows and other systems to the boot menu | | `[performance] parallel_boot` | `true` | | OpenRC: start services in parallel (`rc_parallel`) | -| `[performance] kernel_parameters` | `[]` | | Added to `loglevel=3 quiet nowatchdog nmi_watchdog=0` (and `zswap.enabled=0` with zram) | +| `[performance] kernel_parameters` | `[]` | | Added to `loglevel=3 quiet nowatchdog nmi_watchdog=0 transparent_hugepage=madvise` (and `zswap.enabled=0` with zram, `amd_pstate=active` on AMD processors); a parameter of the same name replaces the default | +| `[performance] fallback_kernel` | `true` | | With a network connection, also install the other kernel (`linux` beside `linux-zen`, `linux-zen` beside the stock one) as a boot menu entry to fall back on; the installed kernel is never removed | | `[drivers] nvidia` | `"auto"` | Driver installation | `"auto"`: install NVIDIA's driver for GeForce GTX 16/RTX and newer when online; `"never"` | | `[services] enable` / `disable` | `[]` | | On top of SegLinux's defaults | | `[[run]] pass`, `command`, `description` | | `RunSynchronous`, `SetupComplete.cmd` | Shell command run in the new system during `specialize` or `setup-complete` | From 766b9764c7bb146a624d435917183e44297711ee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:38:18 +0000 Subject: [PATCH 18/23] chore(ci): allow libc in seg, keep unsafe to proc.rs, check the new files - The dependency guard allows seg, lexopt and libc (3 lines of cargo tree). - unsafe may only appear in src/game/proc.rs. - visudo -cf checks the sudoers rule seg game run depends on. - shellcheck covers the seg-game OpenRC script (the runit one is already matched by the glob). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- .github/workflows/ci.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0214499..81737c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,10 +79,17 @@ jobs: # Without the test cfg, where unwrap and expect are compile errors. - run: cargo clippy --locked -- -D warnings - run: cargo test --locked - - name: Dependencies stay minimal (seg and lexopt only) + - name: Dependencies stay minimal (seg, lexopt and libc only) run: | cargo tree --locked -e normal --prefix none | sort -u - test "$(cargo tree --locked -e normal --prefix none | sort -u | wc -l)" -le 2 + test "$(cargo tree --locked -e normal --prefix none | sort -u | wc -l)" -le 3 + # The only unsafe code is the few libc calls of src/game/proc.rs. + - name: unsafe stays in one file + run: | + test "$(grep -rEl '\bunsafe[[:space:]]*(\{|fn|impl|extern)' segctl/src)" = "segctl/src/game/proc.rs" + - name: sudoers rule is valid + run: | + sudo visudo -cf dist/sudoers-seg-game - name: Release binary size run: | cargo build --locked --release @@ -106,7 +113,7 @@ jobs: data/desktop/labwc/autostart data/system/seglinux-terminal data/system/restore-trimmed-files \ data/system/profile-gaming.sh data/greeter/seglinux-greeter-session data/system/zram-setup \ tools/seg/dist/fstrim-weekly tools/seg/dist/runit/*/run tools/seg/dist/runit/seg-zram/finish \ - tools/seg/dist/openrc/seg-tune tools/seg/dist/openrc/seg-zram + tools/seg/dist/openrc/seg-tune tools/seg/dist/openrc/seg-zram tools/seg/dist/openrc/seg-game shellcheck -s bash pkg/segctl/segctl.install shellcheck -s bash iso/iso-profiles/seglinux-lite/live-overlay/etc/skel/.bash_profile - name: YAML, JSON and XML syntax From 3024bc2543f42af368a813330a18940bbc1044fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:43:09 +0000 Subject: [PATCH 19/23] docs: seg game, Steam launch options, check reference, benchmark method, changelog - docs/gaming.md (the existing page; a second docs/GAMING.md would collide with it on case-insensitive checkouts): the seg game walkthrough, Steam and Lutris launch options, what the boost changes and how it is undone, a table of every seg game check line, safety notes, the updated tuning table and a stock-versus-seg-game benchmark method with MangoHud logs and a summary script (tested against an independent calculation on synthetic frame times; no benchmark numbers are given). - tools/seg/README.md and docs/seg.md: the game commands, the gaming tune profile, the second dependency, the code layout and the privilege model. - README.md and a new CHANGELOG.md. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- CHANGELOG.md | 87 ++++++++++++++ README.md | 22 ++-- docs/gaming.md | 278 ++++++++++++++++++++++++++++++++++++++++++-- docs/seg.md | 5 +- tools/seg/README.md | 79 ++++++++++--- 5 files changed, 434 insertions(+), 37 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f407d10 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,87 @@ +# Changelog + +Notable changes to SegLinux. The project has no version numbers yet, so this +lists what changed since the last ISO build of 19 October 2026 (`4ca2f45`). +Measurements and the reasons behind each removal are in +[docs/lean-audit.md](docs/lean-audit.md). + +## Unreleased + +### Added + +- **`seg game`**, a small gaming layer in the `seg` tool, the same on OpenRC, + runit, dinit and s6 ([docs/gaming.md](docs/gaming.md#seg-game)): + - `seg game run -- CMD` (Steam: `seg game run -- %command%`) raises the CPU + governor and energy preference and an AMD GPU's power level while a game + runs, gives the game a little more CPU and the best I/O priority, holds + off the screen saver, can pin it to the V-Cache chiplet of a two-chiplet + X3D CPU, and restores everything when the game ends, on Ctrl+C, SIGTERM, + SIGHUP or a crash of `seg`. + - `seg game check`: PASS/WARN/FAIL/SKIP for Vulkan, the GPU driver, 32-bit + drivers, NTSYNC, `vm.max_map_count`, open files, kernel, fallback kernel, + CPU governor and driver, boost permission, hugepages, split lock, + compaction and mitigations, each with the exact fix. + - `seg game setup [--yes]`: the driver packages for the GPUs found in the + computer, and only those. + - `seg game profiles`, per-game profiles in `~/.config/seg/games.toml` + (governor, CPU pinning, MangoHud, gamescope arguments, nice). + - `seg game apply` and the **`seg-game` service** (OpenRC, runit, dinit, + s6): the gaming sysctls and `/dev/ntsync` permissions at boot. + - `seg game audio on|off`: low-latency PipeWire, off by default. + - `seg game unsafe-boost on|off`: `mitigations=off`, the only way to get + it, behind a warning and `--i-understand`. + - `seg tune gaming`: low swappiness, warm caches, short write-back, CPU + left dynamic. + - A sudoers rule for the `gamemode` group (two commands, nothing else), a + MangoHud preset and PipeWire drop-ins under `/usr/share/seg` (all off + until asked for). +- Setup installs the **other kernel** (`linux` beside `linux-zen`, or the + reverse) as a boot menu entry to fall back on when online + (`[performance] fallback_kernel`); the installed kernel is never removed. +- Kernel command line: `transparent_hugepage=madvise` everywhere and + `amd_pstate=active` on AMD processors (an answer file's parameter of the + same name replaces the default). +- `vm.compaction_proactiveness = 0` with the other gaming sysctls. +- NVIDIA: `nvidia_drm modeset=1 fbdev=1` (Wayland compositors, VT switch). +- `seg` (`tools/seg`, package `segctl`): `info`, `svc`, `clean`, `tune`, + `zram`, `doctor` for OpenRC, runit, dinit and s6, with the `seg-zram` and + `seg-tune` services and a weekly `fstrim` cron job. +- `ripgrep`, `fd`, `eza` and `zoxide` in the full edition. +- `docs/lean-audit.md`: what the images weigh before and after, the bloat + table, the bugs found, what was not verified. +- CI: a job for `seg` (fmt, clippy, tests, release size, dependency and + `unsafe` guards, `visudo -cf` of the sudoers rule), `udevadm verify` for + the udev rules, a check for repeated packages, more `shellcheck` targets. + +### Changed + +- `wine` -> `wine-staging` (the Staging patches: esync/fsync/NTSYNC, game + fixes) and `lib32-mangohud` added in the full edition. +- Open-file hard limit 524288 -> 1048576 (the kernel's maximum); the + `gamemode` group may use `nice -10`. +- Shader caches: Mesa 4 GB -> 10 GB; NVIDIA's shader cache set to 10 GB and + no longer deleted when full (`/etc/profile.d`, so on every init). +- `makepkg` builds with every thread; the I/O scheduler rule skips partitions + and covers virtual disks. + +### Removed + +- From the full image: `linux-headers` (325 MiB; Setup downloads the running + kernel's headers together with the NVIDIA DKMS driver), `spectacle` and + `opencv`, the X server and its drivers (`xorg-xwayland` stays), `inxi`, + and five packages that were listed twice. The full ISO is 4.7 % smaller and + 475 MiB less is installed. +- `cupsd` is no longer started by default (the packages stay; `seg svc enable + cupsd`). + +### Fixed + +- The NVIDIA DKMS driver could only build because the headers were on every + image: Setup now installs them in that step. +- The artools patch that reads the kernel version without headers applies to + every kernel, not only the non-stock ones. +- The I/O scheduler udev rule matched partitions (errors in the log) and + missed virtual disks. +- `data/system/zram-setup` and the udev rules were not checked by CI. +- `seg doctor` called OpenRC services of the `shutdown` runlevel (`killprocs`, + `mount-ro`, `savecache`) failed; found by booting the Lite image. diff --git a/README.md b/README.md index 1d71170..f8f9c69 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,15 @@ published. kitty as the terminal (File Explorer → Dolphin, Store → Discover, ...). The terminal opens with system information under the SegLinux logo (fastfetch). -* **Built for gaming.** linux-zen kernel, Steam with Proton, Lutris, - GameMode, MangoHud and gamescope; a Steam "Gaming Mode" session; variable - refresh rate and tearing for fullscreen games; zram swap, NTSYNC, I/O - scheduler and sysctl tuning; NVIDIA's driver installed automatically on - GeForce GTX 16/RTX cards. See [docs/gaming.md](docs/gaming.md). +* **Built for gaming.** linux-zen kernel (with the stock kernel beside it to + fall back on), Steam with Proton, Lutris, Wine Staging, GameMode, MangoHud + and gamescope; a Steam "Gaming Mode" session; variable refresh rate and + tearing for fullscreen games; zram swap, NTSYNC, I/O scheduler and sysctl + tuning; NVIDIA's driver installed automatically on GeForce GTX 16/RTX + cards, and only for hardware that is there. `seg game run -- %command%` in + Steam's launch options boosts the CPU and GPU while a game runs and puts + everything back after; `seg game check` says what is missing, with the + fix. See [docs/gaming.md](docs/gaming.md). * **Runs Windows programs.** Double-click an `.exe`, `.msi`, `.lnk` or `.bat`, or type `./setup.exe`: `segrun` inspects the program, prepares a Wine prefix (with DXVK/VKD3D-Proton when the program uses Direct3D) and @@ -103,10 +107,12 @@ published. or dinit; rolling release. * **`seg`, one small tool for the system.** `seg info`, `seg svc` (the same commands on OpenRC, runit, dinit and s6), `seg clean`, `seg tune` - (light, balanced or performance), `seg zram` and `seg doctor`, which finds - problems and prints their fixes. A single Rust binary of about 550 KiB; see + (light, balanced, gaming or performance), `seg zram`, `seg game` (see + above) and `seg doctor`, which finds problems and prints their fixes. A + single Rust binary of about 700 KiB; see [tools/seg](tools/seg/README.md). What the images weigh and what was taken - off them: [docs/lean-audit.md](docs/lean-audit.md). + off them: [docs/lean-audit.md](docs/lean-audit.md). What changed: + [CHANGELOG.md](CHANGELOG.md). ## Website diff --git a/docs/gaming.md b/docs/gaming.md index 6b16b0b..f500bbf 100644 --- a/docs/gaming.md +++ b/docs/gaming.md @@ -46,7 +46,7 @@ Steam and Lutris keep their own settings for the games they start. | Area | SegLinux default | Why | |------|------------------|-----| | Init system | **OpenRC** with parallel start (`rc_parallel="YES"`); runit and dinit builds are available | OpenRC is Artix's most used init. It applies `binfmt.d`, `sysctl.d` and `modules-load.d` natively, which the tuning below relies on, and starting services in parallel keeps boot fast. runit boots about as fast but leaves some of those files to its own scripts. | -| Kernel | **linux-zen** | Arch's kernel variant tuned for desktop responsiveness and low latency; `-k linux` builds with the stock kernel | +| Kernel | **linux-zen**, with the stock `linux` kernel beside it as a boot menu entry | linux-zen is Arch's kernel variant tuned for desktop responsiveness and low latency (and has NTSYNC); `-k linux` builds with the stock kernel. Setup installs the other kernel too when online, so a bad update never leaves the machine without a kernel to boot (GRUB lists it under *Advanced options*). Neither is ever removed. | | Desktop | SegLinux Rust shell on **labwc** | A light Wayland compositor: little RAM and CPU left for games. Plasma stays available as a fallback session. | | Swap | **zram** (compressed swap in RAM, up to 8 GiB) | Avoids stutter from swapping to disk when a game fills memory | @@ -56,9 +56,10 @@ Steam and Lutris keep their own settings for the games they start. |---------|-----| | Steam (with Proton) | Steam games; turn on Proton for all titles in Steam → Settings → Compatibility | | Lutris | GOG, Epic, Battle.net, emulators and other launchers | -| Wine, winetricks | Windows programs and games outside Steam (`segrun`) | +| Wine Staging, winetricks | Windows programs and games outside Steam (`segrun`); Staging adds the esync/fsync/NTSYNC patches and game fixes | | GameMode | Performance governor, I/O priority and GPU performance level while a game runs | -| MangoHud | FPS, frame time, CPU/GPU load overlay | +| `seg game` | The same idea without a daemon, plus a readiness check and the driver packages for the GPUs found ([below](#seg-game)) | +| MangoHud (64 and 32 bit) | FPS, frame time, CPU/GPU load overlay | | gamescope | Valve's gaming compositor: frame limiting, FSR upscaling, Gaming Mode | | Mesa, Vulkan (AMD, Intel, NVK) + 32-bit | Graphics drivers, including for 32-bit games | | NVIDIA driver | Installed by Setup on GeForce GTX 16 / RTX and newer when online (see below) | @@ -94,6 +95,247 @@ gamescope = true gamescope_args = ["-W", "2560", "-H", "1440", "-r", "165", "--adaptive-sync"] ``` +## seg game + +`seg game` is part of [`seg`](seg.md), the small Rust tool that is already on +the system (`segctl` package). It works the same on OpenRC, runit, dinit and +s6, and it needs no daemon of its own. + +```sh +sudo seg game setup --yes # the graphics driver packages for the GPUs in this PC +seg game check # PASS / FAIL for everything games need, with the fix +seg game run -- ./game # run a game with the computer set up for it +``` + +### Steam launch options + +Right-click the game → Properties → General → Launch options: + +| Goal | Launch options | +|---|---| +| Boost while the game runs | `seg game run -- %command%` | +| Boost, and MangoHud (64 or 32 bit) | `seg game run -- mangohud %command%` | +| Boost, inside gamescope (render 1440p, 165 Hz, adaptive sync) | `seg game run -- gamescope -W 2560 -H 1440 -r 165 --adaptive-sync -f -- %command%` | +| A Proton variable as well | `PROTON_LOG=1 seg game run -- %command%` | +| Only the cheap parts (no governor or GPU change) | `seg game run --no-boost -- %command%` | +| Show what would happen and exit | `seg game run --dry-run -- %command%` | + +For Lutris, put `seg game run --` in front of the runner's command (Configure → +System options → Command prefix: `seg game run --`). Games outside Steam: +`seg game run -- wine Game.exe` or `seg game run -- segrun Game.exe`. + +### What `seg game run` does + +While the game runs, and only then: + +| Change | How | Why | Undone | +|---|---|---|---| +| CPU governor `performance` and energy preference `performance` | through the one root helper (`sudo -n seg game boost`, the `gamemode` group; or directly as root) | A dynamic governor can be slow to raise clocks on short bursts, which shows as low "1 % lows" | when the game ends | +| AMD GPU: `power_dpm_force_performance_level = high` | same helper | The driver can sit at a low clock while the CPU is the bottleneck, then ramp late | when the game ends | +| Game gets `nice -4` and best-effort I/O priority 0 | the game's own process (`nice -10` is allowed to the `gamemode` group by `limits.d`) | A little more CPU than a browser or a download in the background | dies with the game | +| Screen saver and idle blanking held off | `org.freedesktop.ScreenSaver.Inhibit` on the session bus (a hand-written D-Bus call; no library) | The screen does not go dark in the middle of a cutscene or with a controller | when the game ends | +| X3D CPUs with two chiplets: the game only uses the cores of the V-Cache chiplet | `--pin vcache` or `pin = "vcache"` in a profile; the chiplet is the one whose L3 cache is bigger (read from `/sys/devices/system/cpu/cpu*/cache`) | Games that like cache run on the cache; the other chiplet clocks higher but has less of it | dies with the game | +| `MESA_SHADER_CACHE_MAX_SIZE=10G`, `__GL_SHADER_DISK_CACHE_SIZE` | environment of the game | See [system tuning](#system-tuning) | dies with the game | + +Several games can run at once: the helper keeps a count (`/run/seg/game-boost`, +mode 0600) of the game processes it was asked for, identified by process id +and start time, so a game that crashed does not keep the boost on and the +last one to end restores the settings that were found. The settings come +back when the game exits, on Ctrl+C, SIGTERM and SIGHUP, and when `seg` +itself panics. If the whole computer loses power the settings are runtime +state anyway: a reboot clears them. + +What it does **not** do: it never changes anything permanently, never turns +on `mitigations=off`, never touches overclocking or voltages, and does not +set the governor to `performance` for the whole system. + +### Per-game profiles + +`~/.config/seg/games.toml`. A profile is picked when its `match` (a glob such +as `eldenring.exe` or `*Launcher*`) fits the name of a file in the command, so +a Proton command line matches the `.exe` at its end. The first profile that +matches wins. A mistake in the file is reported with its line number and the +game starts anyway, without profiles. + +```toml +[[game]] +match = "eldenring.exe" +governor = "performance" # or "keep" (no boost for this game), or a governor name +pin = "vcache" # "vcache", "off" or a CPU list such as "0-7,16-23" +mangohud = true +mangohud_config = "preset" # SegLinux's preset (/usr/share/seg/mangohud-gaming.conf) +gamescope = ["-W", "2560", "-H", "1440", "-r", "165", "--adaptive-sync"] +nice = -5 + +[[game]] +match = "cs2" +pin = "0-7" +``` + +`seg game profiles` checks the file and lists what it found. + +### Reading `seg game check` + +Every line is `PASS` (fine), `WARN` (works, could be better), `FAIL` (games +may crash or run badly) or `SKIP` (cannot be checked here, for example no GPU +in a virtual machine). Each WARN and FAIL prints the exact command that fixes +it, and the exit status is 1 when anything fails. This is the real output of +a build container with no GPU and the stock kernel, to show the format: + +``` +[FAIL] vulkan vulkaninfo did not run (not installed, or no Vulkan driver) + fix: sudo pacman -S vulkan-tools vulkan-icd-loader (then: sudo seg game setup) +[SKIP] gpu driver no graphics card found in /sys/class/drm (virtual machine?) +[FAIL] ntsync /dev/ntsync does not exist: the module is not loaded + fix: sudo modprobe ntsync (always: echo ntsync | sudo tee /etc/modules-load.d/10-ntsync.conf) +[WARN] compaction vm.compaction_proactiveness = 20: the kernel defragments memory in the background, which can stall a game + fix: sudo sysctl -w vm.compaction_proactiveness=0 (always: /etc/sysctl.d/80-seglinux-gaming.conf) +[PASS] mitigations the kernel's default security mitigations are on + +3 passed, 4 to improve, 4 failed +``` + +| Check | Passes when | Why it matters | +|---|---|---| +| vulkan | `vulkaninfo` lists a real device | Nearly every current game (DXVK, VKD3D-Proton) needs Vulkan; only the software renderer (llvmpipe/lavapipe), or no `vulkaninfo`, is a FAIL | +| gpu driver | each GPU has its kernel driver: `amdgpu`, `i915`/`xe`, `nvidia` (or `nouveau` on a pre-Turing NVIDIA card, which has nothing better) | No driver bound, or `nouveau` on a card NVIDIA's driver supports, is a FAIL with `seg game setup --yes`; an unknown driver is a WARN | +| 32-bit drivers | the `lib32-` packages for the GPU are installed (and `[lib32]` is enabled) | Steam and many games are 32-bit | +| ntsync | the kernel is 6.14 or newer, `/dev/ntsync` exists and every user may open it | The kernel implementation of Windows synchronisation, used by Wine 11 and recent Proton | +| max_map_count | `vm.max_map_count` is at least 1048576 | Several games (and Proton) crash with "out of memory" below it | +| open files | the hard limit of open files is at least 524288 | esync keeps a file descriptor per Windows synchronisation object | +| kernel | `linux-zen`, or any other tuned kernel | A WARN, not a FAIL: the stock kernel works | +| fallback kernel | a second kernel is installed | If an update breaks one, the other boots | +| cpu governor | a dynamic governor at rest; SKIP without cpufreq (a VM) | `performance` all the time is a WARN: hot, loud and no better while idle. `seg game run` raises it only while a game runs | +| cpu driver (AMD CPUs only) | `amd-pstate` in active mode (`amd-pstate-epp`) | The CPU picks its own clocks, guided by the energy hint `seg game run` sets; otherwise a WARN with the kernel parameter | +| game boost | you are root, or `sudo -n -l` shows that the helper is allowed (member of `gamemode`) | Without it `seg game run` still starts the game but cannot change the governor or the GPU; the FAIL says whether `segctl` is missing, you are not in the group, or the sudoers file is broken | +| hugepages | transparent hugepages are `madvise` | `always` can stall on memory compaction | +| split lock | `kernel.split_lock_mitigate = 0` | Otherwise games that do split locks are slowed down on purpose | +| compaction | `vm.compaction_proactiveness = 0` | Background defragmentation can show as stutter | +| mitigations | `mitigations=off` is not on the kernel command line | A WARN if it is, with how to undo it | + +### Other `seg game` commands + +* `sudo seg game setup [--yes] [--games] [--lutris]` finds the graphics cards + (`/sys/class/drm`, names from `lspci`) and lists the packages **for those + cards only**: Mesa and the Vulkan driver plus their `lib32-` versions for + AMD and Intel; NVIDIA's open kernel driver (`nvidia-open-dkms` with the + headers of the running kernel, `nvidia-utils`, `lib32-nvidia-utils`, + `nvidia-settings`) for GeForce GTX 16 / RTX and newer; the open-source NVK + driver for older NVIDIA cards, because NVIDIA's current driver does not + support them. `--yes` runs `pacman -S --needed --noconfirm` with the list; + `--games` adds Steam, gamescope, MangoHud (64 and 32 bit), Wine Staging and + winetricks; `--lutris` adds Lutris. It also tells you when `[lib32]` is not + enabled in `/etc/pacman.conf`. +* `sudo seg game apply` applies the gaming sysctls and makes `/dev/ntsync` + usable now; the `seg-game` service runs it at boot on every init. +* `seg game audio on|off|status` lowers PipeWire's buffer to 256 samples at + 48 kHz (about 5 ms), for you only (`~/.config/pipewire/...conf.d`). **Off by + default**: it costs CPU and can make sound crackle on a weak or busy + machine; `seg game audio off` puts the defaults back. +* `sudo seg game unsafe-boost on|off|status` is the only way to get + `mitigations=off`. See [safety](#safety-notes). +* `sudo seg tune gaming` (a saved profile, like `light`/`balanced`/`performance`): + `vm.swappiness` 10 without zram (180 with zram, the value Setup writes), + `vm.vfs_cache_pressure` 50, shorter write-back, and the governor left + dynamic, because `seg game run` raises it only while a game runs. + +### Safety notes + +* **No global unsafe tweaks.** Everything above is either a kernel setting + that Valve's SteamOS and the gaming kernels also use, or lives only as long + as a game runs. The governor is never `performance` all day on its own. +* **The root helper is narrow.** `/etc/sudoers.d/seg-game` lets the `gamemode` + group (Setup puts every user in it; the `gamemode` package uses the same + group) run exactly `/usr/bin/seg game boost on ` and `boost off `. + The program checks that the arguments are a process id and a governor that + exists, and writes only the cpufreq and `amdgpu` power files. A member of + the group can therefore change the CPU governor and the AMD GPU power level + and nothing else; that is the same trust `gamemoded` asks for. +* **`mitigations=off` is opt-in and loud.** `sudo seg game unsafe-boost on` + prints a warning and does nothing unless you add `--i-understand`; it + edits `GRUB_CMDLINE_LINUX_DEFAULT`, rebuilds the boot menu and takes + effect at the next boot. `off` undoes it, `status` says whether it is on, + and `seg game check` shows a WARN while it is. Measure first (see below): + on recent CPUs it is often within the run-to-run noise. Do not use it on + a computer you share or use for banking. +* **The fallback kernel is never removed**, by Setup, `seg clean` or + anything else here. +* **Drivers only for hardware that is there.** `seg game setup` and Setup read + the PCI devices; with no NVIDIA card no NVIDIA package is selected. + +## Measuring: stock versus `seg game run` + +There are no benchmark numbers in this repository, on purpose: they depend on +the CPU, GPU, game, scene and driver. This is how to produce your own and +tell a real difference from noise. Use MangoHud's log, which records frame +times, so you get the average frame rate **and** the "1 % lows" (the +stutter), which is where a governor or a pinning change shows up. + +1. **Fix everything else.** Same game, same settings, same resolution, same + scene (the game's built-in benchmark, or the same 60 seconds walked the + same way), plugged in, nothing else running, the display's refresh rate + unchanged. Turn the in-game frame limiter off for both runs, or set it + the same. +2. **Warm the shader cache** by playing the scene once and discarding that + run, otherwise the first run measures shader compilation. +3. **Log the frame times.** In Steam's launch options, run A (stock) and run B: + + ``` + A: MANGOHUD_CONFIG="output_folder=$HOME/bench/stock,log_interval=0,autostart_log=15,log_duration=60,no_display" mangohud %command% + B: MANGOHUD_CONFIG="output_folder=$HOME/bench/seg,log_interval=0,autostart_log=15,log_duration=60,no_display" seg game run -- mangohud %command% + ``` + + `autostart_log=15,log_duration=60` records 60 seconds starting 15 seconds + after launch, so load screens are left out; `log_interval=0` writes one + row per frame. (A MangoHud version that ignores it writes a row every + 100 ms: still fine for comparing, less exact for lows; use the same + version for both.) `mkdir -p ~/bench/stock ~/bench/seg` first. + `seg game run --no-boost -- ...` is a third variant that shows how much + of any difference is the governor and GPU level and how much the rest. +4. **Repeat at least five times each, alternating** (A B A B ...), so that + thermals and background tasks hit both alike. Reboot between the two + sessions only if you suspect a leak. +5. **Summarise each log.** MangoHud's CSV has a few lines of system + information, then a header line starting with `fps,` and one row per + frame; this reads the `frametime` column (milliseconds): + + ```sh + summarise() { # usage: summarise ~/bench/stock/*.csv + for f in "$@"; do + awk -F, '/^fps,/ { for (i = 1; i <= NF; i++) if ($i == "frametime") c = i; next } + c && $c + 0 > 0 { print $c }' "$f" | LC_ALL=C sort -n | + awk -v name="$f" ' + { ft[NR] = $1; sum += $1 } + END { + n = NR + if (!n) { print name ": no frames"; exit } + k1 = int(n * 0.99); if (k1 < 1) k1 = 1 + k01 = int(n * 0.999); if (k01 < 1) k01 = 1 + for (i = k1 + 1; i <= n; i++) { s1 += ft[i]; n1++ } # slowest 1 % + for (i = k01 + 1; i <= n; i++) { s01 += ft[i]; n01++ } # slowest 0.1 % + printf "%s frames=%d avg=%.1f fps 1%%low=%.1f fps 0.1%%low=%.1f fps p99=%.2f ms max=%.2f ms\n", + name, n, 1000 * n / sum, n1 ? 1000 * n1 / s1 : 0, n01 ? 1000 * n01 / s01 : 0, ft[k1], ft[n] + }' + done + } + summarise ~/bench/stock/*.csv; summarise ~/bench/seg/*.csv + ``` + + (Any awk will do.) "1 % low" here is the frame rate + of the slowest 1 % of frames taken together; other tools define it as + the 99th-percentile frame time, so only compare numbers made by the same + script. +6. **Read the result honestly.** Take the median of each side. A difference + smaller than the spread between runs of the *same* side is noise. The + average rarely moves; if `seg game run` helps, it is usually the 1 % and + 0.1 % lows and the p99 frame time. On a PC that already runs the + `performance` governor all the time, expect no difference at all; the + benefit is the same performance without paying for it when idle. + +For `mitigations=off`, repeat the A/B across a reboot with and without +`seg game unsafe-boost on`; if the lows do not move by more than the spread, +leave it off. + ## System tuning | Setting | File | Effect | @@ -101,10 +343,18 @@ gamescope_args = ["-W", "2560", "-H", "1440", "-r", "165", "--adaptive-sync"] | `vm.max_map_count = 2147483642` | `/etc/sysctl.d/80-seglinux-gaming.conf` | Games that map many memory areas no longer crash (SteamOS value) | | `kernel.split_lock_mitigate = 0` | same | No deliberate slowdown of games that perform split locks | | `nmi_watchdog=0`, `nowatchdog`, watchdog modules blacklisted | kernel command line (Setup), `/usr/lib/modprobe.d/seglinux-nowatchdog.conf` | Fewer periodic interrupts | +| `vm.compaction_proactiveness = 0` | same | No background memory defragmentation while a game allocates (the default is 20); a program that needs a contiguous block still gets it by compacting on demand. What SteamOS sets. | | `fs.inotify.max_user_watches` | sysctl | Launchers watching many files | +| `transparent_hugepage=madvise` | kernel command line (Setup) | Huge pages only for programs that ask (games, databases); `always` can stall on compaction | +| `amd_pstate=active` | kernel command line (Setup, AMD processors only) | The CPU picks its frequency itself, guided by the energy hint `seg game run` sets. Kernels or CPUs without it ignore it. | | I/O schedulers: NVMe none, SSD mq-deadline, HDD BFQ | `/usr/lib/udev/rules.d/60-seglinux-ioschedulers.rules` | Low latency while games stream assets | | NTSYNC | `/etc/modules-load.d/10-ntsync.conf`, udev rule | Windows synchronisation in the kernel, used by Wine 11 and Proton | -| esync file limit | `/etc/security/limits.d/90-seglinux-esync.conf` | For Proton/wine-staging's esync | +| Open files `nofile` hard limit 1048576 | `/etc/security/limits.d/90-seglinux-esync.conf` | esync/fsync keep a file descriptor per Windows synchronisation object (1048576 is the kernel's maximum); Wine raises its own soft limit up to it | +| `nice -10` for the `gamemode` group | same file | Lets `seg game run` raise a game's priority | +| Shader caches 10 GB: `MESA_SHADER_CACHE_MAX_SIZE`, `__GL_SHADER_DISK_CACHE_SIZE`, `__GL_SHADER_DISK_CACHE_SKIP_CLEANUP=1` | `/etc/profile.d/seglinux-gaming.sh` (every init; a value you set wins) | With several games installed, Mesa's 1 GB default evicts compiled shaders and games stutter while recompiling; NVIDIA's driver deletes its cache when full unless told not to. Disk space only. | +| `nvidia_drm modeset=1 fbdev=1` | `/usr/lib/modprobe.d/seglinux-nvidia.conf` | Needed by Wayland compositors and a proper VT switch; does nothing without NVIDIA's driver | +| THP stays `madvise`, mitigations stay on | kernel defaults | Deliberately not tuned further; see [safety](#safety-notes) | +| Applied at boot by the `seg-game` service | `seg game apply` | The sysctls and `/dev/ntsync` permissions on OpenRC, runit, dinit and s6 alike | | zram + `vm.swappiness = 180` | written by Setup | Compressed swap in RAM; `zswap.enabled=0` avoids double compression | | Quiet boot, `loglevel=3` | kernel command line (Setup) | Faster, quieter boot | | Variable refresh rate and tearing | `/usr/share/seglinux/labwc/rc.xml` (`adaptiveSync`, `allowTearing`) | FreeSync/G-Sync for fullscreen games, lowest input latency | @@ -122,8 +372,12 @@ Turing generation or newer (GeForce GTX 16, RTX 20 and later), it installs `nvidia-settings` when the network is reachable (this also updates the system). Without a network connection, or with `[drivers] nvidia = "never"`, the open-source nouveau/NVK driver is used; install the packages later with -`sudo pacman -Syu nvidia-open-dkms nvidia-utils lib32-nvidia-utils`. Older -NVIDIA cards are not supported by NVIDIA's current driver and keep nouveau. +`sudo pacman -Syu nvidia-open-dkms nvidia-utils lib32-nvidia-utils`, or +`sudo seg game setup --yes`, which also adds the headers of the running kernel +that DKMS needs. Older NVIDIA cards (before Turing) are not supported by +NVIDIA's current driver and keep the open-source driver (NVK). The driver is +loaded with `nvidia_drm modeset=1 fbdev=1` +(`/usr/lib/modprobe.d/seglinux-nvidia.conf`). ## Intel graphics and low-end PCs @@ -155,10 +409,10 @@ What SegLinux does for low-end machines: * GameMode switches the CPU to its performance governor while a game runs, and on integrated GPUs, when the GPU is the busy part, lowers the CPU's share of the power budget so the GPU can clock higher (`/etc/gamemode.ini`). -* Mesa's shader cache may grow to 4 GB (`MESA_SHADER_CACHE_MAX_SIZE`, set - in `/etc/profile.d/seglinux-gaming.sh`): DXVK 2 and VKD3D-Proton keep - compiled shaders there, and the 1 GB default makes games recompile them - (stutter) once several are installed. +* Mesa's shader cache may grow to 10 GB (`MESA_SHADER_CACHE_MAX_SIZE`, set + in `/etc/profile.d/seglinux-gaming.sh`, which also sizes NVIDIA's cache): + DXVK 2 and VKD3D-Proton keep compiled shaders there, and the 1 GB default + makes games recompile them (stutter) once several are installed. * Hardware video decoding (`intel-media-driver`, `libva-intel-driver`) takes video playback off the CPU. * The **Lite** ISO (well under 1 GB, see [building.md](building.md#the-lite-edition)) @@ -201,6 +455,10 @@ More on Intel GPUs: ## Checking the setup +`seg game check` is the quick one: Vulkan, the GPU driver and its 32-bit +version, NTSYNC, `vm.max_map_count`, the open files limit, the kernels, the CPU +governor, whether the boost helper may run, hugepages, split lock, compaction +and mitigations, each with its fix (see [above](#reading-seg-game-check)). `segrun doctor` reports Wine, winetricks, Vulkan, NTSYNC, GameMode, MangoHud, gamescope, split lock mitigation, zram, the open file limit, `vm.max_map_count` and binfmt support. diff --git a/docs/seg.md b/docs/seg.md index 7ad690e..4bb4aaa 100644 --- a/docs/seg.md +++ b/docs/seg.md @@ -1,8 +1,9 @@ # The seg tool `seg` looks after a SegLinux system: `seg info`, `seg svc` (services on -OpenRC, runit, dinit or s6), `seg clean`, `seg tune`, `seg zram` and +OpenRC, runit, dinit or s6), `seg clean`, `seg tune`, `seg zram`, `seg game` +(boost while a game runs, a readiness check, the right driver packages) and `seg doctor`. It is a small Rust program in `tools/seg` and is installed from -the `segctl` package. +the `segctl` package. For games, see [gaming.md](gaming.md#seg-game). Everything about it is in [tools/seg/README.md](../tools/seg/README.md). diff --git a/tools/seg/README.md b/tools/seg/README.md index 5eb1b60..3000af6 100644 --- a/tools/seg/README.md +++ b/tools/seg/README.md @@ -1,16 +1,18 @@ # seg One small Rust program to look after a SegLinux system, the same on OpenRC, -runit, dinit and s6. No async runtime and one dependency -([lexopt](https://crates.io/crates/lexopt), a minimal argument parser); the -release binary is about 550 KiB. +runit, dinit and s6. No async runtime and two dependencies +([lexopt](https://crates.io/crates/lexopt), a minimal argument parser, and +[libc](https://crates.io/crates/libc), for the few system calls a game +launcher needs); the release binary is about 700 KiB. ``` seg info distro, kernel, init, uptime, memory, packages, disk seg svc list|enable|disable|start|stop [name] seg clean [--apply] orphans, old cached packages, old logs -seg tune light|balanced|performance|apply|status +seg tune light|balanced|gaming|performance|apply|status seg zram on|off|status +seg game run|check|setup|profiles|apply|audio|unsafe-boost seg doctor look for problems and print their fixes ``` @@ -68,15 +70,20 @@ Dry run unless `--apply` is given. ## seg tune -| | light | balanced | performance | -|---|---|---|---| -| `vm.swappiness` | 100 | 60 | 10 | -| `vm.vfs_cache_pressure` | 150 | 100 | 50 | -| `vm.dirty_ratio` | 10 | 20 | 15 | -| `vm.dirty_background_ratio` | 5 | 10 | 5 | -| CPU governor (first available) | powersave, conservative, schedutil, ondemand | schedutil, ondemand, powersave | performance | -| energy/performance preference | power | balance_performance | performance | - +| | light | balanced | gaming | performance | +|---|---|---|---|---| +| `vm.swappiness` | 100 | 60 | 10 | 10 | +| `vm.vfs_cache_pressure` | 150 | 100 | 50 | 50 | +| `vm.dirty_ratio` | 10 | 20 | 15 | 15 | +| `vm.dirty_background_ratio` | 5 | 10 | 5 | 5 | +| CPU governor (first available) | powersave, conservative, schedutil, ondemand | schedutil, ondemand, powersave | schedutil, ondemand, powersave (dynamic) | performance | +| energy/performance preference | power | balance_performance | balance_performance | performance | + +`gaming` is for a computer that is mostly used for games: it keeps caches and +writes back in short bursts like `performance`, but leaves the CPU dynamic, +because `seg game run` raises it only while a game runs (and an idle desktop +does not need a hot CPU). `performance` keeps the governor on `performance` +all the time. * With zram swap on, swappiness is at least 180, the value Setup writes for zram (/etc/sysctl.d/90-seglinux-zram.conf): swapping to compressed memory is cheaper than re-reading files from disk. @@ -103,11 +110,42 @@ uses, leaves such a device entirely alone. It also turns zswap off (it would compress the same pages twice) and sets `vm.page-cluster=0`. `seg zram off` swaps off and frees the memory; `status` shows size, use and compression ratio. The `seg-zram` service runs it at boot. +## seg game + +For games; the walkthrough, Steam launch options, the benchmark method and the +safety notes are in [docs/gaming.md](../../docs/gaming.md#seg-game). + +| command | what it does | +|---|---| +| `seg game run [--no-boost] [--pin vcache\|off\|LIST] [--nice N] [--profiles FILE] [--dry-run] -- CMD` | run `CMD` with the governor, GPU level, priorities, screen saver and CPU pinning set for a game, and put it all back when it ends (Ctrl+C, SIGTERM, SIGHUP, a panic included) | +| `seg game check` | PASS/WARN/FAIL/SKIP for Vulkan, GPU driver, lib32 drivers, ntsync, `vm.max_map_count`, open files, kernel, fallback kernel, governor, boost permission, hugepages, split lock, compaction, mitigations; the fix under each; exit 1 if any FAIL | +| `seg game setup [--yes] [--games] [--lutris]` | the driver packages for the GPUs in this computer (from `/sys/class/drm` and `lspci`) and the command that installs them; `--yes` runs it | +| `seg game profiles [FILE]` | check `~/.config/seg/games.toml` and list what it holds | +| `seg game apply` | the gaming sysctls and `/dev/ntsync` permissions now (the `seg-game` service) | +| `seg game audio on\|off\|status` | low-latency PipeWire for the current user | +| `seg game unsafe-boost on\|off\|status` | `mitigations=off`; needs `--i-understand` after a warning | + +How the code is arranged (`segctl/src/game/`): `hw.rs` reads the CPU (vendor, +cache sizes for the V-Cache chiplet, cpufreq) and the GPUs (`/sys/class/drm`, +PCI ids) from a root directory, so the tests use the fake trees in +`segctl/tests/fixtures`; `toml.rs` and `profile.rs` are the profile parser +(a small TOML subset that names the line of a mistake); `boost.rs` is the +root-side state machine (`/run/seg/game-boost`, a lock, owners identified by +pid and start time); `proc.rs` holds **all** the `unsafe` code of the crate +(a handful of libc calls, each with a `SAFETY` comment; CI checks that no +other file has any); `dbus.rs` is a minimal D-Bus client for the screen-saver +inhibit; `run.rs` is `seg game run`; `check.rs`, `setup.rs`, `apply.rs`, +`audio.rs` and `unsafe_boost.rs` are the rest. The privilege model: users +never write to `/sys` themselves; `seg game run` calls +`sudo -n /usr/bin/seg game boost on|off PID [GOVERNOR]`, which the sudoers +file allows for the `gamemode` group, and the helper validates every +argument. + ## seg doctor | check | looks at | fix it prints | |---|---|---| -| services | enabled services that are stopped or crashed | the init's start command, or `seg svc disable` | +| services | services enabled for boot that are stopped or crashed (OpenRC's `shutdown` and `nonetwork` runlevels are not started at boot and are not counted) | the init's start command, or `seg svc disable` | | firmware | `dmesg`: "Direct firmware load for ... failed with error -2" | `pacman -F` to find the package, then install and reboot | | disk | /, /home, /var, /usr under 1 GiB free or 90 % full; /boot under 20 MiB | `seg clean --apply` | | pacman | /var/lib/pacman/db.lck with no pacman running | `sudo rm /var/lib/pacman/db.lck` | @@ -121,9 +159,16 @@ back, which also appears in the log; the report says so. ## Shipped in the package (`pkg/segctl`) * `/usr/bin/seg` -* `seg-tune` and `seg-zram` for OpenRC (`/etc/init.d`), runit - (`/etc/runit/sv`) and dinit (`/etc/dinit.d`); the package enables both - (`seg svc --offline enable`) when it is installed, also in the ISO build's chroot +* `seg-tune`, `seg-zram` and `seg-game` for OpenRC (`/etc/init.d`), runit + (`/etc/runit/sv`), dinit (`/etc/dinit.d`) and, for `seg-game`, s6 + (`/etc/s6/sv`); the package enables them (`seg svc --offline enable`) when + it is installed, also in the ISO build's chroot +* `/etc/sudoers.d/seg-game`: lets the `gamemode` group run + `seg game boost on|off ` as root, nothing else (the package creates + the group) +* `/usr/share/seg/`: the MangoHud preset and the low-latency PipeWire + drop-ins, which do nothing until `mangohud_config = "preset"` or + `seg game audio on` asks for them * `/etc/seg/tune.conf` and `/etc/sysctl.d/99-seg.conf`: the balanced profile * `/usr/lib/seg/fstrim-weekly` and `/etc/cron.d/seg-fstrim`: cron checks every hour and trims once a week (a stamp in /var/lib/seg), so a computer that From 0d857afd14c750cf20fdfd62d73cb0aac757d6f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:45:11 +0000 Subject: [PATCH 20/23] fix: drop lib32-mangohud, it is not in the Artix repositories CI's pacman -Sp on a real Artix container reported it as the only missing package of the full edition (wine-staging resolves). Removed from the profile and from seg game setup --games, with a test so it is not added back unchecked, and documented: 32-bit games get no MangoHud overlay unless the user builds lib32-mangohud from the AUR, which the base does not use. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- CHANGELOG.md | 3 ++- docs/gaming.md | 6 +++--- iso/iso-profiles/seglinux/profile.yaml | 3 +-- tools/seg/segctl/src/game/setup.rs | 9 +++++---- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f407d10..8c1c678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,8 @@ Measurements and the reasons behind each removal are in ### Changed - `wine` -> `wine-staging` (the Staging patches: esync/fsync/NTSYNC, game - fixes) and `lib32-mangohud` added in the full edition. + fixes) in the full edition. `lib32-mangohud` was tried and is not in the + Artix repositories, so 32-bit games get no MangoHud overlay. - Open-file hard limit 524288 -> 1048576 (the kernel's maximum); the `gamemode` group may use `nice -10`. - Shader caches: Mesa 4 GB -> 10 GB; NVIDIA's shader cache set to 10 GB and diff --git a/docs/gaming.md b/docs/gaming.md index f500bbf..33ffaac 100644 --- a/docs/gaming.md +++ b/docs/gaming.md @@ -59,7 +59,7 @@ Steam and Lutris keep their own settings for the games they start. | Wine Staging, winetricks | Windows programs and games outside Steam (`segrun`); Staging adds the esync/fsync/NTSYNC patches and game fixes | | GameMode | Performance governor, I/O priority and GPU performance level while a game runs | | `seg game` | The same idea without a daemon, plus a readiness check and the driver packages for the GPUs found ([below](#seg-game)) | -| MangoHud (64 and 32 bit) | FPS, frame time, CPU/GPU load overlay | +| MangoHud | FPS, frame time, CPU/GPU load overlay for 64-bit games (the Artix repositories have no 32-bit MangoHud; 32-bit games get no overlay unless you build `lib32-mangohud` from the AUR) | | gamescope | Valve's gaming compositor: frame limiting, FSR upscaling, Gaming Mode | | Mesa, Vulkan (AMD, Intel, NVK) + 32-bit | Graphics drivers, including for 32-bit games | | NVIDIA driver | Installed by Setup on GeForce GTX 16 / RTX and newer when online (see below) | @@ -114,7 +114,7 @@ Right-click the game → Properties → General → Launch options: | Goal | Launch options | |---|---| | Boost while the game runs | `seg game run -- %command%` | -| Boost, and MangoHud (64 or 32 bit) | `seg game run -- mangohud %command%` | +| Boost, and MangoHud (64-bit games) | `seg game run -- mangohud %command%` | | Boost, inside gamescope (render 1440p, 165 Hz, adaptive sync) | `seg game run -- gamescope -W 2560 -H 1440 -r 165 --adaptive-sync -f -- %command%` | | A Proton variable as well | `PROTON_LOG=1 seg game run -- %command%` | | Only the cheap parts (no governor or GPU change) | `seg game run --no-boost -- %command%` | @@ -223,7 +223,7 @@ a build container with no GPU and the stock kernel, to show the format: `nvidia-settings`) for GeForce GTX 16 / RTX and newer; the open-source NVK driver for older NVIDIA cards, because NVIDIA's current driver does not support them. `--yes` runs `pacman -S --needed --noconfirm` with the list; - `--games` adds Steam, gamescope, MangoHud (64 and 32 bit), Wine Staging and + `--games` adds Steam, gamescope, MangoHud (64-bit only), Wine Staging and winetricks; `--lutris` adds Lutris. It also tells you when `[lib32]` is not enabled in `/etc/pacman.conf`. * `sudo seg game apply` applies the gaming sysctls and makes `/dev/ntsync` diff --git a/iso/iso-profiles/seglinux/profile.yaml b/iso/iso-profiles/seglinux/profile.yaml index 8420b1f..1dc673e 100644 --- a/iso/iso-profiles/seglinux/profile.yaml +++ b/iso/iso-profiles/seglinux/profile.yaml @@ -133,8 +133,7 @@ rootfs: - lutris # GOG, Epic, Battle.net and other launchers - gamemode # CPU/GPU tuning while a game runs - lib32-gamemode - - mangohud # FPS and frame-time overlay (64-bit games) - - lib32-mangohud # the same for 32-bit games + - mangohud # FPS and frame-time overlay (64-bit games; Artix has no lib32-mangohud) - gamescope # gaming compositor, Gaming Mode session - dosbox - mesa diff --git a/tools/seg/segctl/src/game/setup.rs b/tools/seg/segctl/src/game/setup.rs index d1c380e..c26a974 100644 --- a/tools/seg/segctl/src/game/setup.rs +++ b/tools/seg/segctl/src/game/setup.rs @@ -23,8 +23,8 @@ and RTX cards, the open-source driver for older NVIDIA cards. Without --yes it only prints them and the command that installs them. With --yes (as root) it runs pacman -S --needed --noconfirm with that list. - --games also Steam, gamescope, MangoHud (64 and 32 bit), Wine Staging - and winetricks + --games also Steam, gamescope, MangoHud (64-bit: the Artix repositories + have no 32-bit MangoHud), Wine Staging and winetricks --lutris also Lutris (a big package; Steam does not need it) "; @@ -153,7 +153,6 @@ pub fn plan( "steam", "gamescope", "mangohud", - "lib32-mangohud", "wine-staging", "winetricks", ] { @@ -392,13 +391,15 @@ mod tests { "steam", "gamescope", "mangohud", - "lib32-mangohud", "wine-staging", "winetricks", "lutris", ] { assert!(all.packages.contains(&pkg.to_string()), "{pkg}"); } + // Not in the Artix repositories (CI's pacman -Sp said so): do not add + // it back without checking. + assert!(!all.packages.contains(&"lib32-mangohud".to_string())); let games_only = plan_games(); assert_eq!( games_only.packages.last().map(String::as_str), From 9105b630f66be42ceea84419dc163ebd8a922fca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:47:48 +0000 Subject: [PATCH 21/23] chore(ci): resolve the packages seg game setup can choose on Artix CI only checked the packages in the ISO profiles, so nvidia-open-dkms, nvidia-utils, lib32-nvidia-utils, nvidia-settings and vulkan-nouveau, which seg game setup (and Setup) install on demand, were never looked up in the repositories. A unit test now pins the union of everything seg game setup can choose (tests/setup-packages.txt; SEG_WRITE_FIXTURES=1 rewrites it) and check-packages.sh -x resolves that list on top of the image in every package job. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- .github/workflows/ci.yml | 4 ++- iso/scripts/check-packages.sh | 25 ++++++++++++---- tools/seg/segctl/src/game/setup.rs | 36 +++++++++++++++++++++++ tools/seg/segctl/tests/setup-packages.txt | 23 +++++++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 tools/seg/segctl/tests/setup-packages.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81737c2..221a568 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,7 +207,9 @@ jobs: pacman-key --populate artix pacman -Sy --noconfirm --needed go-yq iso/scripts/chaotic-aur.sh - - run: iso/scripts/check-packages.sh -p ${{ matrix.profile }} -i ${{ matrix.init }} -k linux-zen + # -x: the packages `seg game setup` can choose (NVIDIA's driver, the + # Vulkan drivers, the games stack) must exist and resolve on top of the image. + - run: iso/scripts/check-packages.sh -p ${{ matrix.profile }} -i ${{ matrix.init }} -k linux-zen -x tools/seg/segctl/tests/setup-packages.txt - name: Packages the Lite edition downloads during Setup if: matrix.profile == 'seglinux-lite' run: iso/scripts/online-packages.sh -i openrc -k linux-zen diff --git a/iso/scripts/check-packages.sh b/iso/scripts/check-packages.sh index 55a9c57..1e76abe 100755 --- a/iso/scripts/check-packages.sh +++ b/iso/scripts/check-packages.sh @@ -4,7 +4,10 @@ # Run on Artix (or in the build container); needs pacman and go-yq. # # iso/scripts/check-packages.sh [-i openrc|runit|dinit|s6] [-k kernel] -# [-p profile] [-c pacman.conf] +# [-p profile] [-c pacman.conf] [-x file] +# +# -x names a file of packages that tools install later (one per line, `#` +# comments): they must exist and resolve together with the root file system. set -euo pipefail iso_dir=$(cd "$(dirname "$0")/.." && pwd) @@ -13,14 +16,16 @@ kernel=linux profile=seglinux conf=$iso_dir/pacman.conf.d/iso-x86_64.conf local_repo=/var/lib/seglinux-repo +extra_file= -while getopts "i:k:p:c:h" opt; do +while getopts "i:k:p:c:x:h" opt; do case $opt in i) init=$OPTARG ;; k) kernel=$OPTARG ;; p) profile=$OPTARG ;; c) conf=$OPTARG ;; - *) sed -n '2,9s/^# \{0,1\}//p' "$0"; exit 2 ;; + x) extra_file=$OPTARG ;; + *) sed -n '2,12s/^# \{0,1\}//p' "$0"; exit 2 ;; esac done @@ -65,6 +70,12 @@ trap 'rm -rf "$db" "$work_conf"' EXIT chmod 755 "$db" cp "$conf" "$work_conf" +extra= +if [[ -n $extra_file ]]; then + [[ -f $extra_file ]] || { echo "no package list at $extra_file" >&2; exit 1; } + extra=$(grep -v '^[[:space:]]*#' "$extra_file" | sed -e "s/^linux-headers$/$kernel-headers/" | sort -u) +fi + skip=() if [[ ! -f $local_repo/seglinux.db ]]; then # The seglinux and segctl packages are built during the ISO build; when @@ -82,7 +93,7 @@ declare -A have=() for name in "${available[@]}"; do have[$name]=1; done missing=() -for name in $rootfs $livefs; do +for name in $rootfs $livefs $extra; do [[ " ${skip[*]} " == *" $name "* ]] && continue [[ -n ${have[$name]:-} ]] && continue # Not a package name: maybe a group or a virtual package someone provides. @@ -111,4 +122,8 @@ resolve() { resolve rootfs $rootfs # shellcheck disable=SC2086 resolve "rootfs+livefs" $rootfs $livefs -echo "All $(echo "$rootfs $livefs" | wc -w) package names of profile '$profile' ($init) are available." +if [[ -n $extra ]]; then + # shellcheck disable=SC2086 + resolve "rootfs+tools ($extra_file)" $rootfs $extra +fi +echo "All $(echo "$rootfs $livefs $extra" | wc -w) package names of profile '$profile' ($init) are available." diff --git a/tools/seg/segctl/src/game/setup.rs b/tools/seg/segctl/src/game/setup.rs index c26a974..e0c1c25 100644 --- a/tools/seg/segctl/src/game/setup.rs +++ b/tools/seg/segctl/src/game/setup.rs @@ -287,6 +287,42 @@ mod tests { ) } + /// Every package `seg game setup` can ever pick, for the CI check that + /// they exist in the Artix repositories (check-packages.sh -x). + #[test] + fn every_package_setup_can_choose_is_in_the_ci_list() { + let mut all = BTreeSet::new(); + for card in [ + "gpu-amd", + "gpu-intel-arc", + "gpu-nvidia-ada", + "gpu-nvidia-pascal", + "gpu-hybrid-laptop", + ] { + let plan = plan(&gpus(&fixture(card)), &name, Some("linux-zen"), true, true); + all.extend(plan.packages); + } + let mut expected = String::from( + "# Every package `seg game setup` can choose, one per line. A test keeps this in step\n\ + # with src/game/setup.rs; CI resolves them on Artix (check-packages.sh -x).\n", + ); + for pkg in &all { + expected.push_str(pkg); + expected.push('\n'); + } + let path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/setup-packages.txt"); + if std::env::var_os("SEG_WRITE_FIXTURES").is_some() { + std::fs::write(&path, &expected).unwrap(); + } + assert_eq!( + std::fs::read_to_string(&path).unwrap_or_default(), + expected, + "run the tests once with SEG_WRITE_FIXTURES=1 to rewrite {}", + path.display() + ); + } + #[test] fn amd_gets_mesa_and_radv_in_both_bitnesses() { let plan = p("gpu-amd"); diff --git a/tools/seg/segctl/tests/setup-packages.txt b/tools/seg/segctl/tests/setup-packages.txt new file mode 100644 index 0000000..e9c0700 --- /dev/null +++ b/tools/seg/segctl/tests/setup-packages.txt @@ -0,0 +1,23 @@ +# Every package `seg game setup` can choose, one per line. A test keeps this in step +# with src/game/setup.rs; CI resolves them on Artix (check-packages.sh -x). +gamescope +lib32-mesa +lib32-nvidia-utils +lib32-vulkan-icd-loader +lib32-vulkan-intel +lib32-vulkan-radeon +linux-zen-headers +lutris +mangohud +mesa +nvidia-open-dkms +nvidia-settings +nvidia-utils +steam +vulkan-icd-loader +vulkan-intel +vulkan-nouveau +vulkan-radeon +vulkan-tools +wine-staging +winetricks From 84f626e1a79ca81132cb08f997d0e0eb0b38e263 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:20:13 +0000 Subject: [PATCH 22/23] fix(upgrade): sudo seglinux-upgrade also builds and installs segctl The from-source upgrade built the seglinux package only, so a system installed before seg existed never received seg, seg game, the seg-game service or the sudoers rule, whatever was merged. It now builds segctl from the same source tarball (tools/seg, as pkg/segctl/Makefile does) and installs both in one pacman -U; a segctl build failure only warns. The CI upgrade job builds from this commit on an Artix container and checks the package, the gamemode group, the sudoers file mode and syntax, the service files and that seg game check and seg game run --dry-run run without a GPU. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- .github/workflows/ci.yml | 13 +++++++++++++ CHANGELOG.md | 3 +++ data/system/seglinux-upgrade | 36 +++++++++++++++++++++++++++++++++--- docs/lean-audit.md | 6 ++++-- docs/updating.md | 8 +++++++- 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 221a568..b5f18d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,6 +302,19 @@ jobs: SUDO_USER=tester SEGLINUX_SOURCE="$PWD" SEGLINUX_VERSION=0.1.0.999999 \ bash data/system/seglinux-upgrade --noconfirm pacman -Q seglinux | grep -q ' 0\.1\.0\.999999-' + # seg (package segctl) comes with it, with its group, sudoers rule + # and services (pacman -U ran the package's install script). + pacman -Q segctl + seg --version + getent group gamemode + test "$(stat -c %a /etc/sudoers.d/seg-game)" = 440 + visudo -cf /etc/sudoers.d/seg-game + test -x /etc/init.d/seg-game && test -f /etc/runit/sv/seg-game/run && test -f /etc/dinit.d/seg-game + ls /etc/dinit.d/boot.d /etc/runlevels/default 2>/dev/null | grep -E '^seg-' || true + # seg game works on a machine with no GPU: it reports, it does not crash. + seg game check > /tmp/game-check.txt || test $? -eq 1 + grep -q 'max_map_count' /tmp/game-check.txt + seg game run --dry-run -- true test -x /usr/bin/seglinux-upgrade && test -L /usr/bin/seglinux-update test ! -e /usr/local/bin/seglinux-upgrade seglinux-shell --version diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c1c678..564aa13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,9 @@ Measurements and the reasons behind each removal are in `gamemode` group may use `nice -10`. - Shader caches: Mesa 4 GB -> 10 GB; NVIDIA's shader cache set to 10 GB and no longer deleted when full (`/etc/profile.d`, so on every init). +- `sudo seglinux-upgrade` (the from-source path) now also builds and + installs `segctl`, so an older install gets `seg`, `seg game`, the + services and the sudoers rule; CI checks this on an Artix container. - `makepkg` builds with every thread; the I/O scheduler rule skips partitions and covers virtual disks. diff --git a/data/system/seglinux-upgrade b/data/system/seglinux-upgrade index 41f465e..662d15d 100755 --- a/data/system/seglinux-upgrade +++ b/data/system/seglinux-upgrade @@ -178,6 +178,7 @@ latest() { # makepkg does not build as root, so it builds as the account that ran sudo. build_seglinux() { local user home version sha installed work top deps packages + local segctl_dir segctl_version segctl_deps segctl_packages=() user=${SUDO_USER:-} if [[ -z $user || $user == root ]]; then warn "SegLinux itself is built from your account: run sudo seglinux-upgrade as yourself, not as root." @@ -218,10 +219,27 @@ build_seglinux() { tar -C "$work/x" -czf "$work/seglinux-$version.tar.gz" "seglinux-$version" fi cp "$work/x/seglinux-$version/packaging/seglinux/"{PKGBUILD,seglinux.install} "$work/" + # seg, the system tool (package segctl: seg game, the zram and tuning + # services), is built from the same source: its recipe, and its folder as + # the source tarball the recipe names. + segctl_dir= + if [[ -f $work/x/seglinux-$version/pkg/segctl/PKGBUILD ]]; then + segctl_dir=$work/segctl + mkdir "$segctl_dir" + cp "$work/x/seglinux-$version/pkg/segctl/"{PKGBUILD,segctl.install} "$segctl_dir/" + segctl_version=$(sed -n 's/^pkgver=//p' "$segctl_dir/PKGBUILD") + tar -C "$work/x/seglinux-$version" --transform "s,^\./tools/seg,segctl-$segctl_version/tools/seg," \ + -czf "$segctl_dir/segctl-$segctl_version.tar.gz" ./tools/seg + fi rm -rf "$work/x" "$work/source.tar.gz" sed -i "s/^pkgver=.*/pkgver=$version/" "$work/PKGBUILD" chown -R "$user:" "$work" - as_user() { (cd "$work" && runuser -u "$user" -- env HOME="$home" CARGO_HOME="$home/.cache/seglinux/cargo" "$@"); } + as_user_in() { + local dir=$1 + shift + (cd "$dir" && runuser -u "$user" -- env HOME="$home" CARGO_HOME="$home/.cache/seglinux/cargo" "$@") + } + as_user() { as_user_in "$work" "$@"; } # What it needs to build and run, as dependencies (pacman -Qdt lists # them once nothing needs them). deps=$(as_user makepkg --printsrcinfo | sed -n 's/^\t\(make\)\{0,1\}depends = //p') || { @@ -229,8 +247,12 @@ build_seglinux() { warn "SegLinux's build recipe could not be read; SegLinux itself is not upgraded this time." return 0 } + segctl_deps= + if [[ -n $segctl_dir ]]; then + segctl_deps=$(as_user_in "$segctl_dir" makepkg --printsrcinfo | sed -n 's/^\t\(make\)\{0,1\}depends = //p') || segctl_dir= + fi # shellcheck disable=SC2086 # one package name per word - pacman -S --needed --noconfirm --asdeps base-devel $deps || { + pacman -S --needed --noconfirm --asdeps base-devel $deps $segctl_deps || { rm -rf "$work" warn "The tools to build SegLinux could not be installed; SegLinux itself is not upgraded this time." return 0 @@ -240,8 +262,16 @@ build_seglinux() { warn "SegLinux $version did not build; your SegLinux stays as it is. Please report it: $source_url/issues" return 0 fi + # seg is a smaller extra: if it does not build, SegLinux is still upgraded. + if [[ -n $segctl_dir ]]; then + if as_user_in "$segctl_dir" makepkg -f --nocheck --noconfirm; then + segctl_packages=("$segctl_dir"/segctl-*.pkg.tar.zst) + else + warn "seg (the segctl package) did not build; it is not upgraded this time." + fi + fi packages=("$work"/seglinux-"$version"-*.pkg.tar.zst) - pacman -U "$@" "${packages[0]}" || warn "SegLinux $version was not installed." + pacman -U "$@" "${packages[0]}" "${segctl_packages[@]}" || warn "SegLinux $version was not installed." rm -rf "$work" } diff --git a/docs/lean-audit.md b/docs/lean-audit.md index 9e0c70e..2f921e7 100644 --- a/docs/lean-audit.md +++ b/docs/lean-audit.md @@ -176,8 +176,10 @@ Found, not changed: the udev rule starts it whatever the answer says. * The two greetd `config.toml` and `pacman.conf` copies in the root overlays are identical to each other (and the first to `data/greeter/greetd.toml`). -* `seglinux-upgrade` updates only the `seglinux` package, so a system - installed before this work does not get `segctl` that way yet. +* `seglinux-upgrade` updated only the `seglinux` package, so a system + installed before this work did not get `segctl`. Fixed in the gaming work: + the from-source upgrade also builds and installs `segctl` (tested in CI on + an Artix container); the signed repository still carries `seglinux` only. ## Decisions for the maintainers diff --git a/docs/updating.md b/docs/updating.md index 9addded..c3201a0 100644 --- a/docs/updating.md +++ b/docs/updating.md @@ -83,7 +83,13 @@ each marked `# Added by seglinux-upgrade.`. It keeps the previous file as When SegLinux is built from source, the source comes over HTTPS from SegLinux's GitHub repository, as the ISO and this command do; pacman installs -the package it builds as a local file, like any AUR package. +the package it builds as a local file, like any AUR package. The same build +also makes `segctl` from the `tools/seg` folder (the `seg` tool: `seg game`, +the zram, tuning and game services, the sudoers rule for the `gamemode` +group) and installs the two together; if only `segctl` fails to build, +SegLinux is still upgraded. SegLinux's signed repository does not carry +`segctl` yet (`.github/workflows/update.yml` builds `seglinux` only), so once +packages are published `seg` still needs `--from-source` or the ISO. ## When an update fails From 4bbb12b15a415f9ed7d399c5654c2b3bc78b9833 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:21:17 +0000 Subject: [PATCH 23/23] fix(seg-game): seg game apply sets hugepages to madvise Booting the Lite image in QEMU showed seg game check warning "transparent hugepages: [always]": linux-zen starts with always, and the madvise parameter is only written to GRUB by Setup, so the live image and a system upgraded with seglinux-upgrade never had it. The seg-game service runs seg game apply at boot on every init, so it now changes always to madvise (and leaves madvise and never alone). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0119819Vrp5vJojQCT1CjWog --- CHANGELOG.md | 3 ++ docs/gaming.md | 5 ++- tools/seg/README.md | 2 +- tools/seg/segctl/src/game/apply.rs | 66 +++++++++++++++++++++++++++--- 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 564aa13..57e8325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,9 @@ Measurements and the reasons behind each removal are in - Setup installs the **other kernel** (`linux` beside `linux-zen`, or the reverse) as a boot menu entry to fall back on when online (`[performance] fallback_kernel`); the installed kernel is never removed. +- Transparent hugepages go from `always` to `madvise` at boot (`seg-game` + service), so the live image and upgraded systems get it too, not only + fresh installs. - Kernel command line: `transparent_hugepage=madvise` everywhere and `amd_pstate=active` on AMD processors (an answer file's parameter of the same name replaces the default). diff --git a/docs/gaming.md b/docs/gaming.md index 33ffaac..0539507 100644 --- a/docs/gaming.md +++ b/docs/gaming.md @@ -226,7 +226,8 @@ a build container with no GPU and the stock kernel, to show the format: `--games` adds Steam, gamescope, MangoHud (64-bit only), Wine Staging and winetricks; `--lutris` adds Lutris. It also tells you when `[lib32]` is not enabled in `/etc/pacman.conf`. -* `sudo seg game apply` applies the gaming sysctls and makes `/dev/ntsync` +* `sudo seg game apply` applies the gaming sysctls, sets transparent + hugepages to `madvise` (from `always` only) and makes `/dev/ntsync` usable now; the `seg-game` service runs it at boot on every init. * `seg game audio on|off|status` lowers PipeWire's buffer to 256 samples at 48 kHz (about 5 ms), for you only (`~/.config/pipewire/...conf.d`). **Off by @@ -345,7 +346,7 @@ leave it off. | `nmi_watchdog=0`, `nowatchdog`, watchdog modules blacklisted | kernel command line (Setup), `/usr/lib/modprobe.d/seglinux-nowatchdog.conf` | Fewer periodic interrupts | | `vm.compaction_proactiveness = 0` | same | No background memory defragmentation while a game allocates (the default is 20); a program that needs a contiguous block still gets it by compacting on demand. What SteamOS sets. | | `fs.inotify.max_user_watches` | sysctl | Launchers watching many files | -| `transparent_hugepage=madvise` | kernel command line (Setup) | Huge pages only for programs that ask (games, databases); `always` can stall on compaction | +| `transparent_hugepage=madvise` | kernel command line (Setup), and `seg game apply` at boot | Huge pages only for programs that ask (games, databases); `always` can stall on compaction. linux-zen starts with `always`, so the live image and a system upgraded with `seglinux-upgrade` get `madvise` from the `seg-game` service (it changes `always` only; `never` is left alone). Found by booting the Lite image: the check said `[always]` before this. | | `amd_pstate=active` | kernel command line (Setup, AMD processors only) | The CPU picks its frequency itself, guided by the energy hint `seg game run` sets. Kernels or CPUs without it ignore it. | | I/O schedulers: NVMe none, SSD mq-deadline, HDD BFQ | `/usr/lib/udev/rules.d/60-seglinux-ioschedulers.rules` | Low latency while games stream assets | | NTSYNC | `/etc/modules-load.d/10-ntsync.conf`, udev rule | Windows synchronisation in the kernel, used by Wine 11 and Proton | diff --git a/tools/seg/README.md b/tools/seg/README.md index 3000af6..a3cb695 100644 --- a/tools/seg/README.md +++ b/tools/seg/README.md @@ -121,7 +121,7 @@ safety notes are in [docs/gaming.md](../../docs/gaming.md#seg-game). | `seg game check` | PASS/WARN/FAIL/SKIP for Vulkan, GPU driver, lib32 drivers, ntsync, `vm.max_map_count`, open files, kernel, fallback kernel, governor, boost permission, hugepages, split lock, compaction, mitigations; the fix under each; exit 1 if any FAIL | | `seg game setup [--yes] [--games] [--lutris]` | the driver packages for the GPUs in this computer (from `/sys/class/drm` and `lspci`) and the command that installs them; `--yes` runs it | | `seg game profiles [FILE]` | check `~/.config/seg/games.toml` and list what it holds | -| `seg game apply` | the gaming sysctls and `/dev/ntsync` permissions now (the `seg-game` service) | +| `seg game apply` | the gaming sysctls, hugepages `always` -> `madvise`, and `/dev/ntsync` permissions now (the `seg-game` service) | | `seg game audio on\|off\|status` | low-latency PipeWire for the current user | | `seg game unsafe-boost on\|off\|status` | `mitigations=off`; needs `--i-understand` after a warning | diff --git a/tools/seg/segctl/src/game/apply.rs b/tools/seg/segctl/src/game/apply.rs index 37f2327..539068b 100644 --- a/tools/seg/segctl/src/game/apply.rs +++ b/tools/seg/segctl/src/game/apply.rs @@ -1,7 +1,7 @@ //! `seg game apply`, run at boot by the `seg-game` service of every init: -//! the gaming sysctls and the permissions of /dev/ntsync. OpenRC and udev do -//! both too; runit, dinit and s6 images may not, and applying twice is -//! harmless. +//! the gaming sysctls, transparent hugepages on `madvise` and the permissions +//! of /dev/ntsync. OpenRC and udev do the sysctls and the device too; runit, +//! dinit and s6 images may not, and applying twice is harmless. use std::fs; use std::os::unix::fs::PermissionsExt; @@ -15,12 +15,23 @@ pub const HELP: &str = "\ Usage: seg game apply Applies /etc/sysctl.d/80-seglinux-gaming.conf now (vm.max_map_count, -kernel.split_lock_mitigate, vm.compaction_proactiveness, ...) and makes -/dev/ntsync usable by every user, so Wine and Proton can use NTSYNC. Needs -root. The seg-game service runs it at boot. +kernel.split_lock_mitigate, vm.compaction_proactiveness, ...), changes +transparent hugepages from `always` to `madvise` (linux-zen starts with +`always`, which can stall a game on memory compaction; `never` is left as it +is) and makes /dev/ntsync usable by every user, so Wine and Proton can use +NTSYNC. Needs root. The seg-game service runs it at boot. "; pub const SYSCTL_FILE: &str = "etc/sysctl.d/80-seglinux-gaming.conf"; +const THP_FILE: &str = "sys/kernel/mm/transparent_hugepage/enabled"; + +/// The word in brackets of `always [madvise] never`. +fn thp_selected(text: &str) -> Option<&str> { + text.split_once('[')? + .1 + .split_once(']') + .map(|(word, _)| word) +} /// Apply the sysctls of the gaming file below `root`; then fix /dev/ntsync. pub fn apply(root: &Path) -> Result> { @@ -45,6 +56,13 @@ pub fn apply(root: &Path) -> Result> { )); } } + let thp = root.join(THP_FILE); + if let Ok(text) = fs::read_to_string(&thp) + && thp_selected(&text) == Some("always") + { + fs::write(&thp, "madvise").context(format!("writing {}", thp.display()))?; + lines.push("transparent hugepages: always -> madvise".into()); + } let ntsync = root.join("dev/ntsync"); if let Ok(meta) = fs::metadata(&ntsync) { let mode = meta.permissions().mode() & 0o777; @@ -117,6 +135,42 @@ mod tests { assert!(again.iter().all(|l| l.starts_with("note:")), "{again:?}"); } + #[test] + fn hugepages_go_from_always_to_madvise_and_nothing_else_changes() { + let t = TempDir::new(); + t.put(THP_FILE, "[always] madvise never\n"); + let lines = apply(t.path()).unwrap(); + assert!( + lines + .iter() + .any(|l| l == "transparent hugepages: always -> madvise"), + "{lines:?}" + ); + assert_eq!( + fs::read_to_string(t.path().join(THP_FILE)).unwrap(), + "madvise" + ); + // madvise is left alone, and so is never (someone chose it). + for already in ["always [madvise] never\n", "always madvise [never]\n"] { + let t = TempDir::new(); + t.put(THP_FILE, already); + let lines = apply(t.path()).unwrap(); + assert!(lines.iter().all(|l| !l.contains("hugepages")), "{lines:?}"); + assert_eq!( + fs::read_to_string(t.path().join(THP_FILE)).unwrap(), + already + ); + } + } + + #[test] + fn the_hugepage_setting_is_read_from_the_brackets() { + assert_eq!(thp_selected("[always] madvise never"), Some("always")); + assert_eq!(thp_selected("always [madvise] never"), Some("madvise")); + assert_eq!(thp_selected("always madvise never"), None); + assert_eq!(thp_selected(""), None); + } + #[test] fn no_file_and_no_device_is_not_an_error() { let t = TempDir::new();