diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a9efbf..b5f18d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,44 @@ 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, 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 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 + 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 +106,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 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 run: | @@ -85,6 +126,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) @@ -142,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 @@ -235,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/.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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..57e8325 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,94 @@ +# 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. +- 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). +- `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) 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 + 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. + +### 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/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/README.md b/README.md index 37c04ae..f8f9c69 100644 --- a/README.md +++ b/README.md @@ -90,17 +90,29 @@ 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 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, 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). What changed: + [CHANGELOG.md](CHANGELOG.md). ## Website diff --git a/crates/seglinux-installer/src/model.rs b/crates/seglinux-installer/src/model.rs index 327bbe5..b1b869d 100644 --- a/crates/seglinux-installer/src/model.rs +++ b/crates/seglinux-installer/src/model.rs @@ -200,6 +200,8 @@ pub fn demo_facts() -> Facts { network: true, 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 7c9621c..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, } } } @@ -323,6 +330,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,12 +341,16 @@ pub const DEFAULT_SERVICES: &[&str] = &[ "NetworkManager", "greetd", "bluetoothd", - "cupsd", "cronie", "syslog-ng", "ntpd", "acpid", "power-profiles-daemon", + // 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 06239c6..9eb18d2 100644 --- a/crates/seglinux-setup/src/facts.rs +++ b/crates/seglinux-setup/src/facts.rs @@ -109,6 +109,11 @@ 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, + /// `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>, @@ -140,6 +145,10 @@ impl Facts { network: has_default_route(), 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") @@ -291,6 +300,32 @@ 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) +} + +/// `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() + && 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 +522,23 @@ 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())); + 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..037435f 100644 --- a/crates/seglinux-setup/src/plan.rs +++ b/crates/seglinux-setup/src/plan.rs @@ -817,15 +817,35 @@ 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", + ]), + )], + }); + } + // 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 { @@ -856,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") @@ -1056,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()) @@ -1064,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()); @@ -1072,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 @@ -1082,6 +1140,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 +1201,8 @@ mod tests { network: true, memory: 4 * GIB, gpus: vec![], + kernel: Some("linux-zen".into()), + cpu_vendor: None, timezones: None, locales: None, keyboards: None, @@ -1189,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. @@ -1270,10 +1336,11 @@ 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\"" + "GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog nmi_watchdog=0 transparent_hugepage=madvise\"" ) ); } @@ -1288,10 +1355,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) @@ -1337,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 10fda2a..1d82eb4 100644 --- a/crates/seglinux-setup/src/tui.rs +++ b/crates/seglinux-setup/src/tui.rs @@ -818,6 +818,8 @@ mod tests { network: true, memory: 8 * GIB, gpus: vec![], + kernel: Some("linux-zen".into()), + cpu_vendor: None, timezones: Some( ["UTC", "Europe/Paris", "Europe/Berlin"] .iter() 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/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/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/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/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/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/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/data/system/zram-setup b/data/system/zram-setup index 26d29e4..a0dc70d 100755 --- a/data/system/zram-setup +++ b/data/system/zram-setup @@ -4,8 +4,18 @@ # 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. 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 --if-unconfigured +fi dev=/dev/zram0 sys=/sys/block/zram0 # Already in use (a second run, or set up by hand): leave it. diff --git a/docs/building.md b/docs/building.md index a7f0716..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) @@ -22,12 +23,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/gaming.md b/docs/gaming.md index 6b16b0b..0539507 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 | 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) | @@ -94,6 +95,248 @@ 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-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%` | +| 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-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, 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 + 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 +344,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), 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 | -| 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 +373,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 +410,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 +456,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/lean-audit.md b/docs/lean-audit.md new file mode 100644 index 0000000..2f921e7 --- /dev/null +++ b/docs/lean-audit.md @@ -0,0 +1,221 @@ +# 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 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 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** | +| `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. + +## 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 | +| `.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` 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 + +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. + +## 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. diff --git a/docs/seg.md b/docs/seg.md new file mode 100644 index 0000000..4bb4aaa --- /dev/null +++ b/docs/seg.md @@ -0,0 +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`, `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. 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/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` | 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 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-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 c0e19d6..1dc673e 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 @@ -38,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). @@ -76,17 +79,18 @@ rootfs: - print-manager # Devices and Printers - discover # Microsoft Store - flatpak - - xorg-server - - xorg-xwayland - - xdg-user-dirs - - xdg-utils + + # 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 - kate # Notepad - kcalc # Calculator - - spectacle # Snipping Tool - gwenview # Photos - okular # PDF reader - ark # compressed folders @@ -120,17 +124,16 @@ rootfs: # Courier New, Calibri) so documents keep their layout - noto-fonts - noto-fonts-emoji - - ttf-liberation - 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) + - 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/iso/scripts/build-in-container.sh b/iso/scripts/build-in-container.sh index cf571f3..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 @@ -131,9 +148,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 +174,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 diff --git a/iso/scripts/check-packages.sh b/iso/scripts/check-packages.sh index 0661c69..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,12 +70,18 @@ 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 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 @@ -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/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/packaging/seglinux/PKGBUILD b/packaging/seglinux/PKGBUILD index b4eead0..7b07001 100644 --- a/packaging/seglinux/PKGBUILD +++ b/packaging/seglinux/PKGBUILD @@ -6,7 +6,7 @@ pkgname=seglinux pkgver=0.1.0 -pkgrel=8 +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" @@ -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' @@ -62,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') @@ -165,6 +167,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" @@ -172,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" @@ -182,6 +186,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" 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..d974cd8 --- /dev/null +++ b/pkg/segctl/PKGBUILD @@ -0,0 +1,95 @@ +# 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=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. +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, 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=( + '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, 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 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 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" + 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..0783b82 --- /dev/null +++ b/pkg/segctl/segctl.install @@ -0,0 +1,40 @@ +# 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 "$@"; do + seg svc --offline enable "$svc" >/dev/null 2>&1 || true + done +} + +post_install() { + make_group + enable_services seg-zram seg-game seg-tune +} + +# 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-game seg-zram; do + seg svc --offline disable "$svc" >/dev/null 2>&1 || true + done +} diff --git a/tools/seg/Cargo.lock b/tools/seg/Cargo.lock new file mode 100644 index 0000000..5a4b2a8 --- /dev/null +++ b/tools/seg/Cargo.lock @@ -0,0 +1,23 @@ +# 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 = "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/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..a3cb695 --- /dev/null +++ b/tools/seg/README.md @@ -0,0 +1,195 @@ +# seg + +One small Rust program to look after a SegLinux system, the same on OpenRC, +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|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 +``` + +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 | 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. +* 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. 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 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, 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 | + +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 | 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` | +| 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`, `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 + 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..a26d03d --- /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-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/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/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/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/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/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/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/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..8c833b3 --- /dev/null +++ b/tools/seg/segctl/Cargo.toml @@ -0,0 +1,18 @@ +[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" +libc = "0.2" 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..6889b03 --- /dev/null +++ b/tools/seg/segctl/src/doctor.rs @@ -0,0 +1,609 @@ +//! `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.runs_at_boot && 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, + runs_at_boot: 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 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)]; + 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/game/apply.rs b/tools/seg/segctl/src/game/apply.rs new file mode 100644 index 0000000..539068b --- /dev/null +++ b/tools/seg/segctl/src/game/apply.rs @@ -0,0 +1,181 @@ +//! `seg game apply`, run at boot by the `seg-game` service of every init: +//! 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; +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, ...), 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> { + 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 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; + 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 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(); + 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..e0c1c25 --- /dev/null +++ b/tools/seg/segctl/src/game/setup.rs @@ -0,0 +1,488 @@ +//! `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-bit: the Artix repositories + have no 32-bit MangoHud), 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", + "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, + ) + } + + /// 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"); + 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", + "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), + 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/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..7393917 --- /dev/null +++ b/tools/seg/segctl/src/main.rs @@ -0,0 +1,237 @@ +//! 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 game; +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|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: + -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(0) => ExitCode::SUCCESS, + Ok(code) => ExitCode::from(code), + Err(e) => { + sys::emit_err(format_args!("seg: {e}")); + ExitCode::from(e.exit_code()) + } + } +} + +/// 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(0); + } + Some(Short('h') | Long("help")) => { + out!("{HELP}"); + return Ok(0); + } + Some(Short('V') | Long("version")) => { + outln!("seg {}", env!("CARGO_PKG_VERSION")); + return Ok(0); + } + 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)?)?, + "game" => return game::run(&mut parser), + "doctor" => { + no_more_args(&mut parser, "doctor")?; + return Ok(u8::from(!doctor::run()?)); + } + other => { + return Err(Error::Usage(format!("unknown command `{other}`"))); + } + } + Ok(0) +} + +/// 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..d263969 --- /dev/null +++ b/tools/seg/segctl/src/svc.rs @@ -0,0 +1,797 @@ +//! `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, + /// 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, +} + +#[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 boot_levels = self.openrc_boot_services(); + let states = if self.live() { + self.states(&enabled) + } else { + HashMap::new() + }; + Ok(available + .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 { + 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 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(); + 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, + runs_at_boot: false, + state: State::Unknown + }, + Service { + name: "b".into(), + enabled: true, + runs_at_boot: 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..e42b994 --- /dev/null +++ b/tools/seg/segctl/src/tune.rs @@ -0,0 +1,998 @@ +//! `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`). +//! +//! 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|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 + 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) + +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 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"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +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 { + pub fn name(self) -> &'static str { + match self { + Mode::Light => "light", + Mode::Balanced => "balanced", + Mode::Performance => "performance", + Mode::Gaming => "gaming", + } + } +} + +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), + "gaming" => Ok(Mode::Gaming), + other => Err(Error::Usage(format!( + "unknown profile `{other}` (light, balanced, gaming 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", + }, + Mode::Gaming => Profile { + swappiness: 10, + vfs_cache_pressure: 50, + dirty_ratio: 15, + dirty_background_ratio: 5, + governors: &["schedutil", "ondemand", "powersave"], + epp: "balance_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(ZRAM_SWAPPINESS) + } 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('/')) + } + + /// 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"))) + } + + 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), + ("gaming", Mode::Gaming), + ("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, gaming 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 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!( + sysctl_values(Mode::Performance, true)[0], + ("vm.swappiness", ZRAM_SWAPPINESS) + ); + assert_eq!( + sysctl_values(Mode::Balanced, true)[0], + ("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, ZRAM_SWAPPINESS); + } + + #[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"), "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(); + 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..536abf4 --- /dev/null +++ b/tools/seg/segctl/src/zram.rs @@ -0,0 +1,654 @@ +//! `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] [--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 +--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. +"; + +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 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. +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, opts: &OnOptions, 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 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 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())?; + } + 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 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"), + tune::ZRAM_SWAPPINESS.to_string(), + ); + } + Ok(format!( + "zram swap is on: {} of 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 on = OnOptions::default(); + while let Some(arg) = parser.next()? { + match arg { + 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); + } + 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), + on, + }) +} + +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.on, 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, &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!( + 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"), "180"); + } + + #[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, + &OnOptions { + size: Some(2 << 30), + ..OnOptions::default() + }, + 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 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, &OnOptions::default(), 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, &OnOptions::default(), 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, &OnOptions::default(), 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, &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"); + } + + #[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") + ); + } +} 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 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