From 4ebe7d11854c5a22c3ed5747977754cb885e69e3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 20:27:26 +0200 Subject: [PATCH 1/3] fix(host): the plugin runner was located by FHS path only, so NixOS never found it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On NixOS every plugin PACKAGE op failed with "the plugin runner isn't installed" on a box where the runner was installed, enabled and running. `runner_command()` checked FHS locations exclusively β€” /usr/bin, the /usr/lib + /usr/share pair behind it, and the ~/.local mirror the SteamOS installer lays down. Nix ships punktfunk-scripting as a derivation of its OWN, so its wrapper is neither beside the host binary nor anywhere under /usr, and no rung could ever match. Service ops go through systemd and were unaffected, which is what made it read as arbitrary: `plugins status` said running/enabled while `plugins add` said not installed. Resolution now matches punktfunk-encode-worker's: PUNKTFUNK_SCRIPTING -> beside the host binary -> PATH -> /usr -> ~/.local. PATH is the rung Nix lands on. The /usr rungs stay AFTER it rather than being dropped, because a systemd unit's PATH need not include /usr/bin. As with the encode worker the env override is deliberately not existence-checked β€” a named path that is wrong should fail naming itself, not fall through to some other runner. Lifted into a pure injected function so the whole table is testable, which is also how the regression is pinned: removing the PATH rung fails the NixOS row specifically. Second half, and the reason the Rust change alone would not have fixed the console: the NixOS module now puts the runner on the HOST UNIT's `path`. The console installs plugins from inside the host service, whose PATH is exactly that unit list β€” `environment.systemPackages` only ever covered an operator's interactive shell. Without it the CLI would have been fixed and the console would not. module-check.nix gains both the positive and the negative assertion, so CI's `nix flake check --no-build` holds the property. The error text named only apt and SteamOS; it now names NixOS and the override. The ~/.local/bin symlink workaround is no longer needed. --- CHANGELOG.md | 32 +++ crates/punktfunk-host/src/plugins.rs | 282 +++++++++++++++++++++--- docs-site/content/docs/configuration.md | 1 + docs-site/content/docs/plugins.mdx | 11 +- packaging/nix/module-check.nix | 33 ++- packaging/nix/nixos-module.nix | 10 +- 6 files changed, 331 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 554963ed..63a634a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -306,6 +306,38 @@ availability probe. The `comm` fast path is still one read for every ordinary di Also reached by the same rung: `gamescope` carries `cap_sys_nice` on a number of distros, so a *wrapped and capped* gamescope was equally invisible to the foreign-gamescope probe. +### NixOS β€” the plugin runner was installed, running, and reported missing + +πŸ›‘ **On NixOS every plugin *package* op failed with "the plugin runner isn't installed", on a box +where the runner was installed, enabled and running.** `punktfunk-host plugins status` said so, and +the console's Plugins screen still refused to install anything. + +The host resolved `punktfunk-scripting` by checking FHS locations exclusively β€” +`/usr/bin/punktfunk-scripting`, the `/usr/lib` + `/usr/share` pair behind it, and the `~/.local` +mirror the SteamOS installer lays down. Nix installs a wrapper at `$out/bin/punktfunk-scripting` in +a **derivation of its own**, so it is neither beside the host binary nor anywhere under `/usr`, and +nothing the resolver looked at could ever match. Service ops (`enable`/`disable`/`status`) go +through systemd and were unaffected, which is what made the failure read as arbitrary: the runner +demonstrably worked, and only the half that had to *locate the executable* was blind. + +Resolution now matches `punktfunk-encode-worker`'s: **`PUNKTFUNK_SCRIPTING` β†’ beside the host +binary β†’ `PATH` β†’ the `/usr` layout β†’ the `~/.local` layout.** `PATH` is the rung Nix lands on. The +`/usr` rungs are kept after it rather than dropped, because a systemd unit's `PATH` need not include +`/usr/bin`. As with the encode worker, an explicit `PUNKTFUNK_SCRIPTING` is deliberately *not* +existence-checked β€” a named path that is wrong should fail naming itself, not fall through to some +other runner. The "not installed" text now also names NixOS and the override, instead of pointing +every operator at `apt`. + +⚠ **Packager-visible, and the other half of the fix:** the NixOS module now puts +`services.punktfunk.scripting.package` on the **host unit's** `path`. `environment.systemPackages` +only ever covered an operator's interactive shell, and the console installs plugins from *inside* +the host service β€” whose `PATH` is exactly that unit list. Without it the CLI would have been fixed +and the console would not. Anyone packaging the host separately wants the same property: the runner +must be on the service's `PATH`, or `PUNKTFUNK_SCRIPTING` set for it. + +The `ln -s "$(command -v punktfunk-scripting)" ~/.local/bin/punktfunk-scripting` workaround is no +longer needed and can be removed. + ### Game Mode on Nobara β€” the WSI opt-out never reached the games πŸ›‘ **v0.27.0's fix for the distro Vulkan WSI layer was clobbered by the session script, so games ran diff --git a/crates/punktfunk-host/src/plugins.rs b/crates/punktfunk-host/src/plugins.rs index 3ae0f0f7..8cdffdf9 100644 --- a/crates/punktfunk-host/src/plugins.rs +++ b/crates/punktfunk-host/src/plugins.rs @@ -35,6 +35,12 @@ const UNIT: &str = "punktfunk-scripting"; #[cfg(target_os = "windows")] const TASK: &str = "PunktfunkScripting"; +/// The runner executable's name. Every non-Windows package installs a wrapper under exactly this +/// name β€” the deb/rpm at `/usr/bin`, the SteamOS installer at `~/.local/bin`, Nix at +/// `$out/bin` β€” so one name covers every layout the resolver walks. +#[cfg(not(target_os = "windows"))] +const RUNNER_BIN: &str = "punktfunk-scripting"; + pub fn main(args: &[String]) -> Result<()> { match args.first().map(String::as_str) { Some("add") | Some("remove") | Some("rm") | Some("uninstall") | Some("list") @@ -161,41 +167,103 @@ pub(crate) fn runner_command() -> Result<(std::path::PathBuf, Vec)> { } #[cfg(not(target_os = "windows"))] { - // The scripting package ships /usr/bin/punktfunk-scripting, a wrapper that runs the bundled - // bun on the runner bundle and forwards "$@" (packaging/debian/build-scripting-deb.sh). - let wrapper = std::path::PathBuf::from("/usr/bin/punktfunk-scripting"); - if wrapper.exists() { - return Ok((wrapper, Vec::new())); - } - // Fall back to the package's private layout in case the wrapper is absent. - let bun = std::path::PathBuf::from("/usr/lib/punktfunk-scripting/bun"); - let runner = std::path::PathBuf::from("/usr/share/punktfunk-scripting/runner-cli.js"); - if bun.exists() && runner.exists() { - return Ok((bun, vec![runner.to_string_lossy().into_owned()])); - } - // Immutable-/usr distros (SteamOS): scripts/steamdeck/install.sh lays the SAME payload - // out user-scoped under ~/.local β€” wrapper, private bun, and bundle mirroring the deb's - // /usr layout β€” because a system package can't exist there. - if let Ok(home) = std::env::var("HOME") { - let home = std::path::Path::new(&home); - let wrapper = home.join(".local/bin/punktfunk-scripting"); - if wrapper.exists() { - return Ok((wrapper, Vec::new())); - } - let bun = home.join(".local/lib/punktfunk-scripting/bun"); - let runner = home.join(".local/share/punktfunk-scripting/runner-cli.js"); - if bun.exists() && runner.exists() { - return Ok((bun, vec![runner.to_string_lossy().into_owned()])); - } - } - bail!( - "the plugin runner isn't installed β€” install it first (Debian/Ubuntu: \ - `sudo apt install punktfunk-scripting`; SteamOS: re-run \ - scripts/steamdeck/install.sh)" + let exe = std::env::current_exe().ok(); + let path_var = std::env::var("PATH").ok(); + let home = std::env::var("HOME").ok(); + resolve_runner_in( + std::env::var("PUNKTFUNK_SCRIPTING").ok().as_deref(), + exe.as_deref().and_then(std::path::Path::parent), + path_var.as_deref(), + home.as_deref().map(std::path::Path::new), + &|p| p.is_file(), ) + .ok_or_else(|| anyhow::anyhow!("{RUNNER_MISSING}")) } } +/// What to say when no rung matched. Shared with [`runtime_status`], so the CLI and the console +/// tell an operator the same thing. +#[cfg(not(target_os = "windows"))] +pub(crate) const RUNNER_MISSING: &str = + "the plugin runner isn't installed β€” install it first (Debian/Ubuntu: `sudo apt install \ + punktfunk-scripting`; SteamOS: re-run scripts/steamdeck/install.sh; NixOS: enable \ + `services.punktfunk.scripting`). If it is installed somewhere else, point PUNKTFUNK_SCRIPTING \ + at the punktfunk-scripting executable."; + +/// The rungs, in order: `PUNKTFUNK_SCRIPTING` β†’ beside the host binary β†’ `PATH` β†’ the packaged +/// `/usr` layout β†’ the user-scoped SteamOS layout. Pure and fully injected so the table can be +/// tested without mutating process env, which races `getenv` in parallel tests. +/// +/// `PATH` is load-bearing rather than a nicety: it is the ONLY rung a Nix install can land on. +/// `punktfunk-scripting` is a derivation of its own there (packaging/nix/packages.nix), so its +/// wrapper is neither beside the host binary nor anywhere under `/usr` β€” the layouts this +/// resolver used to check exclusively, which is why a fully working NixOS box reported the runner +/// as not installed. +#[cfg(not(target_os = "windows"))] +fn resolve_runner_in( + env: Option<&str>, + exe_dir: Option<&std::path::Path>, + path_var: Option<&str>, + home: Option<&std::path::Path>, + exists: &dyn Fn(&std::path::Path) -> bool, +) -> Option<(std::path::PathBuf, Vec)> { + use std::path::{Path, PathBuf}; + + // The two-file layout: a private bun plus the runner bundle, which the deb/rpm and the SteamOS + // installer both lay down beside their wrapper. Only a rung when BOTH halves are present. + let pair = |bun: PathBuf, runner: PathBuf| -> Option<(PathBuf, Vec)> { + (exists(&bun) && exists(&runner)) + .then(|| (bun, vec![runner.to_string_lossy().into_owned()])) + }; + + // The operator's own override, and deliberately NOT existence-checked: whoever names a path is + // entitled to a failure that names it back, where falling through to a runner that happens to + // be installed would hide the typo behind a working install. + if let Some(v) = env.map(str::trim).filter(|v| !v.is_empty()) { + return Some((PathBuf::from(v), Vec::new())); + } + // Beside the host binary β€” a source tree or any relocatable layout shipping both in one prefix. + if let Some(p) = exe_dir.map(|d| d.join(RUNNER_BIN)).filter(|p| exists(p)) { + return Some((p, Vec::new())); + } + if let Some(p) = path_var + .into_iter() + .flat_map(|v| v.split(':')) + .filter(|d| !d.is_empty()) + .map(|d| Path::new(d).join(RUNNER_BIN)) + .find(|p| exists(p)) + { + return Some((p, Vec::new())); + } + // The packaged /usr layout (packaging/debian/build-scripting-deb.sh). Still checked explicitly + // after `PATH` because a systemd unit can carry a PATH that does not include /usr/bin. + let wrapper = Path::new("/usr/bin").join(RUNNER_BIN); + if exists(&wrapper) { + return Some((wrapper, Vec::new())); + } + if let Some(cmd) = pair( + Path::new("/usr/lib").join(RUNNER_BIN).join("bun"), + Path::new("/usr/share") + .join(RUNNER_BIN) + .join("runner-cli.js"), + ) { + return Some(cmd); + } + // Immutable-/usr distros (SteamOS): scripts/steamdeck/install.sh lays the SAME payload out + // user-scoped under ~/.local, because a system package can't exist there. + let home = home?; + let wrapper = home.join(".local/bin").join(RUNNER_BIN); + if exists(&wrapper) { + return Some((wrapper, Vec::new())); + } + pair( + home.join(".local/lib").join(RUNNER_BIN).join("bun"), + home.join(".local/share") + .join(RUNNER_BIN) + .join("runner-cli.js"), + ) +} + // ---- service ops ------------------------------------------------------------------------------ #[cfg(target_os = "linux")] @@ -277,9 +345,7 @@ pub(crate) fn runtime_status() -> RuntimeStatus { detail: if installed { String::new() } else { - "the plugin runner package isn't installed (Debian/Ubuntu: `sudo apt install \ - punktfunk-scripting`)" - .into() + RUNNER_MISSING.into() }, } } @@ -807,3 +873,151 @@ fn enable() -> Result<()> { fn disable() -> Result<()> { bail!("the plugin runner is only available on Linux and Windows hosts") } + +#[cfg(all(test, not(target_os = "windows")))] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + + /// Build an `exists` probe over a fixed set of paths. + fn present(ps: Vec) -> impl Fn(&Path) -> bool { + move |p: &Path| ps.iter().any(|q| q == p) + } + + /// Every layout the resolver has to serve, in one table β€” the regression guard for the NixOS + /// report (a runner on `PATH` and nowhere else read as "not installed"). + #[test] + fn runner_resolution_table() { + let beside = Path::new("/opt/punktfunk/bin"); + let nix = Path::new("/run/current-system/sw/bin"); + let home = Path::new("/home/deck"); + + // The rung a Nix install lands on: NOT beside the host binary, NOT under /usr β€” `PATH` + // only. This is the whole bug. + let exists = present(vec![nix.join(RUNNER_BIN)]); + assert_eq!( + resolve_runner_in( + None, + Some(beside), + Some(nix.to_str().unwrap()), + None, + &exists + ), + Some((nix.join(RUNNER_BIN), Vec::new())) + ); + + // An explicit override wins over every discovery… + let exists = present(vec![beside.join(RUNNER_BIN)]); + assert_eq!( + resolve_runner_in( + Some("/nix/store/abc/bin/punktfunk-scripting"), + Some(beside), + Some("/usr/bin"), + Some(home), + &exists + ), + Some(("/nix/store/abc/bin/punktfunk-scripting".into(), Vec::new())) + ); + // …and is not existence-checked, so a typo surfaces as a spawn failure naming the path + // rather than silently running some other runner. + assert_eq!( + resolve_runner_in(Some("/nope/pf"), Some(beside), None, None, &exists), + Some(("/nope/pf".into(), Vec::new())) + ); + // Empty/whitespace reads as unset, not as a path. + assert_eq!( + resolve_runner_in(Some(" "), Some(beside), None, None, &exists), + Some((beside.join(RUNNER_BIN), Vec::new())) + ); + // Beside the host binary beats PATH. + let exists = present(vec![beside.join(RUNNER_BIN), nix.join(RUNNER_BIN)]); + assert_eq!( + resolve_runner_in( + None, + Some(beside), + Some(nix.to_str().unwrap()), + None, + &exists + ), + Some((beside.join(RUNNER_BIN), Vec::new())) + ); + // PATH is walked entry by entry, skipping empties. + let exists = present(vec![nix.join(RUNNER_BIN)]); + assert_eq!( + resolve_runner_in( + None, + Some(Path::new("/nowhere")), + Some(":/nope:/run/current-system/sw/bin"), + None, + &exists + ), + Some((nix.join(RUNNER_BIN), Vec::new())) + ); + + // The deb/rpm wrapper, found even when the unit's PATH omits /usr/bin. + let exists = present(vec![PathBuf::from("/usr/bin").join(RUNNER_BIN)]); + assert_eq!( + resolve_runner_in( + None, + Some(Path::new("/nowhere")), + Some("/nope"), + None, + &exists + ), + Some((PathBuf::from("/usr/bin").join(RUNNER_BIN), Vec::new())) + ); + // …and its private two-file layout when the wrapper is absent. + let bun = PathBuf::from("/usr/lib").join(RUNNER_BIN).join("bun"); + let cli = PathBuf::from("/usr/share") + .join(RUNNER_BIN) + .join("runner-cli.js"); + let exists = present(vec![bun.clone(), cli.clone()]); + assert_eq!( + resolve_runner_in(None, None, None, None, &exists), + Some((bun, vec![cli.to_string_lossy().into_owned()])) + ); + // Half of that layout is not a rung β€” a partial install must fall through, not spawn a + // bun with no script. + let exists = present(vec![PathBuf::from("/usr/lib").join(RUNNER_BIN).join("bun")]); + assert_eq!(resolve_runner_in(None, None, None, None, &exists), None); + + // SteamOS: the same payload, user-scoped. Reached only via HOME. + let exists = present(vec![home.join(".local/bin").join(RUNNER_BIN)]); + assert_eq!( + resolve_runner_in(None, None, None, Some(home), &exists), + Some((home.join(".local/bin").join(RUNNER_BIN), Vec::new())) + ); + assert_eq!(resolve_runner_in(None, None, None, None, &exists), None); + let bun = home.join(".local/lib").join(RUNNER_BIN).join("bun"); + let cli = home + .join(".local/share") + .join(RUNNER_BIN) + .join("runner-cli.js"); + let exists = present(vec![bun.clone(), cli.clone()]); + assert_eq!( + resolve_runner_in(None, None, None, Some(home), &exists), + Some((bun, vec![cli.to_string_lossy().into_owned()])) + ); + + // Nothing anywhere: the "not installed" rung the error text speaks for. + let exists = present(vec![]); + assert_eq!( + resolve_runner_in(None, Some(beside), Some("/nope"), Some(home), &exists), + None + ); + } + + /// The operator-facing miss must name NixOS β€” the report's second half was that the error + /// pointed a NixOS operator at `apt`. + #[test] + fn the_missing_runner_error_names_every_platform_it_can_be_installed_on() { + for hint in [ + "apt install", + "steamdeck/install.sh", + "NixOS", + "PUNKTFUNK_SCRIPTING", + ] { + assert!(RUNNER_MISSING.contains(hint), "missing hint: {hint}"); + } + } +} diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index b7c4ea52..94fbbd5a 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -244,6 +244,7 @@ notes for context. | `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. | | `PYROWAVE_QUEUE_PRIORITY` | `realtime` *(default)* Β· `high` Β· `off` | [PyroWave](/docs/pyrowave) sessions only β€” the *intent*, forwarded to whichever process does the encode. PyroWave encodes on the same GPU shader cores a game uses, so a demanding game can starve it and the frame rate drops. This asks the driver to schedule the encode ahead of the game. `realtime` tries the strongest class and falls back to `high`; `high` asks only for the middle one; `off` disables the request. A driver that refuses simply encodes at normal priority β€” it can never stop a session starting. Granting the request needs the `CAP_SYS_NICE` capability, which the Linux packages give to `punktfunk-encode-worker` and **never** to `punktfunk-host` β€” a host holding any capability cannot be identified by KWin and loses desktop streaming entirely. Do not `setcap` the host to "make this work"; see [Running as a service](/docs/running-as-a-service#gpu-scheduling-priority). Set `off` if you see the desktop stutter while streaming. | | `PUNKTFUNK_ENCODE_WORKER` | path Β· `off` | Where the host looks for `punktfunk-encode-worker`, the small capability-carrying helper that owns the priority-elevated [PyroWave](/docs/pyrowave) encode (previous row). Unset, the host looks beside its own binary and then on `PATH`, which is right for every package β€” set it only when the worker lives somewhere unusual. **NixOS needs it and the module sets it for you:** a file capability cannot live on a read-only nix store path, so the worker is exposed through `security.wrappers` and this points the host at that wrapper. `off` forces the encode back into the host process at default priority β€” a debug escape hatch, not a tuning knob. Every failure short of that is already handled: a missing binary, a worker that will not start, or one that dies mid-session falls back to encoding in-process with one line in the log, and never drops the session. | +| `PUNKTFUNK_SCRIPTING` | path | Where the host looks for `punktfunk-scripting`, the runner that performs every [plugin](/docs/plugins) package op (`plugins add`/`remove`/`list`, and the console's store installs). Unset, the host looks beside its own binary, then on `PATH`, then in the packaged `/usr` and `~/.local` layouts β€” right for every package, so set it only when the runner lives somewhere unusual. Like the row above it is **not** existence-checked: a path you name is a path you get, so a typo fails naming itself instead of quietly running a different runner. Worth knowing: the console runs installs inside the host *service*, whose `PATH` is normally much shorter than your login shell's β€” if `punktfunk-host plugins add` works and the console says the runner isn't installed, that gap is why, and this is the fix. | ## Diagnostics diff --git a/docs-site/content/docs/plugins.mdx b/docs-site/content/docs/plugins.mdx index 2185f519..e89c30b9 100644 --- a/docs-site/content/docs/plugins.mdx +++ b/docs-site/content/docs/plugins.mdx @@ -305,7 +305,16 @@ installer's `PATH` change, or call the exe by full path. On Linux the host packa [same RPM repo you installed the host from](/docs/fedora). On Arch: `sudo pacman -Syu punktfunk-scripting` (a full `-Syu`, like every other install from that repo). On SteamOS, re-run `scripts/steamdeck/install.sh` (or -`scripts/steamdeck/update.sh`). On Windows, re-run the installer and keep the scripting component. +`scripts/steamdeck/update.sh`). On NixOS it comes with `services.punktfunk.scripting.enable` (on by +default whenever the host is). On Windows, re-run the installer and keep the scripting component. + +If the runner *is* installed and the host still says it isn't, the host could not find the +`punktfunk-scripting` executable. It looks beside its own binary, then on `PATH`, then in the +packaged `/usr` and `~/.local` layouts β€” so a runner installed somewhere else needs +`PUNKTFUNK_SCRIPTING` pointed at it (see [Configuration](/docs/configuration)). Note that the +console installs plugins from inside the host *service*, whose `PATH` is usually much shorter than +your shell's: a runner that `punktfunk-host plugins add` finds and the console does not is that +difference, and the env var is the fix. **Where a plugin's log output goes** β€” the console's **Logs** page, under the **Plugins** filter. The runner ships everything your plugins print to the host, so a plugin's own lines sit next to the diff --git a/packaging/nix/module-check.nix b/packaging/nix/module-check.nix index 8397120c..20b994f7 100644 --- a/packaging/nix/module-check.nix +++ b/packaging/nix/module-check.nix @@ -76,8 +76,10 @@ let ]; }).config; - # Every scenario keeps `gamescopeHdr = false`: it is the one option whose default would pull a - # real (stub, here) gamescope onto the unit PATH, and nothing below is about that. + # Every scenario keeps `gamescopeHdr = false`: its default would pull a real (stub, here) + # gamescope onto the host unit's PATH, and nothing below is about that. `scripting` is left at + # its default (on with the host) precisely BECAUSE the runner belongs on that PATH β€” see the + # "not just in systemPackages" check. desktop = evalWith { services.punktfunk.host = { enable = true; @@ -108,6 +110,15 @@ let }; }; + # A host that has opted out of the runner β€” the negative half of the PATH check. + noScripting = evalWith { + services.punktfunk.host = { + enable = true; + gamescopeHdr = false; + }; + services.punktfunk.scripting.enable = false; + }; + clientOnly = evalWith { services.punktfunk.client.enable = true; }; unit = cfg: name: cfg.systemd.user.units."${name}.service".text; @@ -243,6 +254,24 @@ let name = "the plugin runner is started by default, like every other channel"; ok = has appliance "punktfunk-scripting" "WantedBy=default.target"; } + { + # The runner has to be on the HOST unit's PATH, not merely in systemPackages. Package ops + # (`plugins add`, and the console's store jobs, which run inside the host process) locate + # `punktfunk-scripting` as an executable, and on NixOS PATH is the only rung that can ever + # match β€” the runner is its own derivation, so it is never beside the host binary and never + # under /usr. systemPackages covers an operator's shell and NOT this unit, which is exactly + # how a running, enabled runner reported itself "not installed" through the console. + name = "the plugin runner is on the host unit's PATH, not just in systemPackages"; + ok = + has appliance "punktfunk-host" "/pf-stub/punktfunk-scripting/bin" + && has desktop "punktfunk-host" "/pf-stub/punktfunk-scripting/bin"; + } + { + # …and only when it is actually installed, so `scripting.enable = false` does not put a + # package the machine never built onto a unit's PATH. + name = "a host without the runner does not carry it on PATH"; + ok = !(has noScripting "punktfunk-host" "/pf-stub/punktfunk-scripting/bin"); + } # --- the client half must not drag the host's system wiring in ----------------------------- { diff --git a/packaging/nix/nixos-module.nix b/packaging/nix/nixos-module.nix index 9efd742b..93929a00 100644 --- a/packaging/nix/nixos-module.nix +++ b/packaging/nix/nixos-module.nix @@ -487,7 +487,15 @@ in ] # The HDR-capable gamescope, if enabled. On PATH rather than pinned through # PUNKTFUNK_GAMESCOPE_BIN so an operator's own override of that env still wins. - ++ optional cfg.host.gamescopeHdr cfg.host.gamescopePackage; + ++ optional cfg.host.gamescopeHdr cfg.host.gamescopePackage + # The plugin runner, if enabled. Package ops (`plugins add`, and the console's store jobs, + # which run INSIDE this service) exec `punktfunk-scripting`; its resolution order is + # PUNKTFUNK_SCRIPTING -> beside the host binary -> PATH -> the /usr and ~/.local layouts. + # On NixOS only the PATH rung can ever match: the runner is a derivation of its OWN, so it + # is never beside the host binary and nothing lands in /usr. `environment.systemPackages` + # covers an operator's interactive shell but NOT this unit, whose PATH is exactly this + # list β€” without it the console reports a running, enabled runner as "not installed". + ++ optional cfg.scripting.enable cfg.scripting.package; # Point the host at the WRAPPED encode worker (see `security.wrappers` above). The host's # own resolution order is PUNKTFUNK_ENCODE_WORKER -> alongside /proc/self/exe -> PATH, and # on NixOS the sibling of the store binary is the UNCAPPED store copy β€” it would run, and -- 2.54.0 From 5bcee83c3479e6f762612cb3b75ceef459df9652 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 20:30:33 +0200 Subject: [PATCH 2/3] test: /bin/true and /bin/false do not exist on NixOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests hardcoded absolute paths into /bin, which on NixOS holds only `sh` β€” so `cargo test` failed there for reasons that had nothing to do with the code under test. `gamelease` only needs a process that exits quickly and successfully, so the bare name resolved through PATH is exactly right. `pyrowave_remote` cannot use a bare name: `spawn_link` pins the binary with `PinnedExe::open`, so a name that PATH would have resolved fails the OPEN instead β€” which takes the spawn-failure rung rather than the handshake rung the test exists to exercise. It resolves a real path off PATH first, keeping the test on the rung it names. --- .../src/enc/linux/pyrowave_remote.rs | 22 ++++++++++++++++--- crates/punktfunk-host/src/gamelease.rs | 4 +++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/pf-encode/src/enc/linux/pyrowave_remote.rs b/crates/pf-encode/src/enc/linux/pyrowave_remote.rs index 7ca4eb06..0961ddc9 100644 --- a/crates/pf-encode/src/enc/linux/pyrowave_remote.rs +++ b/crates/pf-encode/src/enc/linux/pyrowave_remote.rs @@ -1016,11 +1016,27 @@ mod tests { ); } - /// Ladder rung: the binary exists and runs but is not a worker. `/bin/false` exits at once, so - /// the handshake reads EOF β€” the same rung a worker that dies during Vulkan bring-up takes. + /// A binary that exists, execs, and exits at once. Resolved off `PATH` rather than hardcoded + /// to `/bin/false`: NixOS ships only `/bin/sh` in `/bin`, and `PinnedExe::open` needs a real + /// path (so a bare name cannot stand in for one β€” it would fail the OPEN and take the + /// spawn-failure rung instead of the handshake rung this exercises). + fn a_binary_that_exits_immediately() -> PathBuf { + std::env::var_os("PATH") + .as_deref() + .map(std::env::split_paths) + .into_iter() + .flatten() + .map(|d| d.join("false")) + .find(|p| p.is_file()) + .expect("a `false` binary on PATH") + } + + /// Ladder rung: the binary exists and runs but is not a worker. It exits at once, so the + /// handshake reads EOF β€” the same rung a worker that dies during Vulkan bring-up takes. #[test] fn a_worker_that_exits_immediately_is_a_handshake_failure() { - let err = spawn_link(Path::new("/bin/false"), ¶ms(), 40_000_000).unwrap_err(); + let err = + spawn_link(&a_binary_that_exits_immediately(), ¶ms(), 40_000_000).unwrap_err(); let text = format!("{err:#}"); assert!( text.contains("handshake"), diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs index ac48ff98..d25d649e 100644 --- a/crates/punktfunk-host/src/gamelease.rs +++ b/crates/punktfunk-host/src/gamelease.rs @@ -1410,7 +1410,9 @@ mod tests { static EXITS: AtomicUsize = AtomicUsize::new(0); EXITS.store(0, Ordering::SeqCst); - let child = std::process::Command::new("/bin/true") + // Resolved through PATH, not `/bin/true`: NixOS ships only `/bin/sh` in `/bin`, so the + // absolute path made this test β€” and nothing else about the code under test β€” fail there. + let child = std::process::Command::new("true") .spawn() .expect("spawn the fake launcher"); let lease = open( -- 2.54.0 From c12476736d3398ad77bc355b885727776a829c3a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 20:40:02 +0200 Subject: [PATCH 3/3] docs(changelog): v0.28.0 was tagged, so these fixes belong in a v0.28.1 section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch is based on the commit v0.28.0 points at, so writing the NixOS runner fix into that section would have credited a released version with a change it does not contain. Moved to a fresh `v0.28.1 β€” in development` section, matching how the v0.28.0 cycle was kept (a `β€” in development` heading the release commit renames). --- CHANGELOG.md | 74 +++++++++++++++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63a634a7..ebc8bcf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,48 @@ with the version table of the release you are moving to, then read **Breaking ch --- +## v0.28.1 β€” in development + +### NixOS β€” the plugin runner was installed, running, and reported missing + +πŸ›‘ **On NixOS every plugin *package* op failed with "the plugin runner isn't installed", on a box +where the runner was installed, enabled and running.** `punktfunk-host plugins status` said so, and +the console's Plugins screen still refused to install anything. + +The host resolved `punktfunk-scripting` by checking FHS locations exclusively β€” +`/usr/bin/punktfunk-scripting`, the `/usr/lib` + `/usr/share` pair behind it, and the `~/.local` +mirror the SteamOS installer lays down. Nix installs a wrapper at `$out/bin/punktfunk-scripting` in +a **derivation of its own**, so it is neither beside the host binary nor anywhere under `/usr`, and +nothing the resolver looked at could ever match. Service ops (`enable`/`disable`/`status`) go +through systemd and were unaffected, which is what made the failure read as arbitrary: the runner +demonstrably worked, and only the half that had to *locate the executable* was blind. + +Resolution now matches `punktfunk-encode-worker`'s: **`PUNKTFUNK_SCRIPTING` β†’ beside the host +binary β†’ `PATH` β†’ the `/usr` layout β†’ the `~/.local` layout.** `PATH` is the rung Nix lands on. The +`/usr` rungs are kept after it rather than dropped, because a systemd unit's `PATH` need not include +`/usr/bin`. As with the encode worker, an explicit `PUNKTFUNK_SCRIPTING` is deliberately *not* +existence-checked β€” a named path that is wrong should fail naming itself, not fall through to some +other runner. The "not installed" text now also names NixOS and the override, instead of pointing +every operator at `apt`. + +⚠ **Packager-visible, and the other half of the fix:** the NixOS module now puts +`services.punktfunk.scripting.package` on the **host unit's** `path`. `environment.systemPackages` +only ever covered an operator's interactive shell, and the console installs plugins from *inside* +the host service β€” whose `PATH` is exactly that unit list. Without it the CLI would have been fixed +and the console would not. Anyone packaging the host separately wants the same property: the runner +must be on the service's `PATH`, or `PUNKTFUNK_SCRIPTING` set for it. + +The `ln -s "$(command -v punktfunk-scripting)" ~/.local/bin/punktfunk-scripting` workaround is no +longer needed and can be removed. + +### `/bin/true` and `/bin/false` are not portable β€” two tests failed on NixOS + +NixOS ships only `sh` in `/bin`, so `gamelease`'s hand-off test and `pyrowave_remote`'s +handshake-rung test failed there for reasons unrelated to the code under test. Both now resolve a +real binary rather than assuming an FHS path. + +--- + ## v0.28.0 180 commits since v0.27.0. @@ -306,38 +348,6 @@ availability probe. The `comm` fast path is still one read for every ordinary di Also reached by the same rung: `gamescope` carries `cap_sys_nice` on a number of distros, so a *wrapped and capped* gamescope was equally invisible to the foreign-gamescope probe. -### NixOS β€” the plugin runner was installed, running, and reported missing - -πŸ›‘ **On NixOS every plugin *package* op failed with "the plugin runner isn't installed", on a box -where the runner was installed, enabled and running.** `punktfunk-host plugins status` said so, and -the console's Plugins screen still refused to install anything. - -The host resolved `punktfunk-scripting` by checking FHS locations exclusively β€” -`/usr/bin/punktfunk-scripting`, the `/usr/lib` + `/usr/share` pair behind it, and the `~/.local` -mirror the SteamOS installer lays down. Nix installs a wrapper at `$out/bin/punktfunk-scripting` in -a **derivation of its own**, so it is neither beside the host binary nor anywhere under `/usr`, and -nothing the resolver looked at could ever match. Service ops (`enable`/`disable`/`status`) go -through systemd and were unaffected, which is what made the failure read as arbitrary: the runner -demonstrably worked, and only the half that had to *locate the executable* was blind. - -Resolution now matches `punktfunk-encode-worker`'s: **`PUNKTFUNK_SCRIPTING` β†’ beside the host -binary β†’ `PATH` β†’ the `/usr` layout β†’ the `~/.local` layout.** `PATH` is the rung Nix lands on. The -`/usr` rungs are kept after it rather than dropped, because a systemd unit's `PATH` need not include -`/usr/bin`. As with the encode worker, an explicit `PUNKTFUNK_SCRIPTING` is deliberately *not* -existence-checked β€” a named path that is wrong should fail naming itself, not fall through to some -other runner. The "not installed" text now also names NixOS and the override, instead of pointing -every operator at `apt`. - -⚠ **Packager-visible, and the other half of the fix:** the NixOS module now puts -`services.punktfunk.scripting.package` on the **host unit's** `path`. `environment.systemPackages` -only ever covered an operator's interactive shell, and the console installs plugins from *inside* -the host service β€” whose `PATH` is exactly that unit list. Without it the CLI would have been fixed -and the console would not. Anyone packaging the host separately wants the same property: the runner -must be on the service's `PATH`, or `PUNKTFUNK_SCRIPTING` set for it. - -The `ln -s "$(command -v punktfunk-scripting)" ~/.local/bin/punktfunk-scripting` workaround is no -longer needed and can be removed. - ### Game Mode on Nobara β€” the WSI opt-out never reached the games πŸ›‘ **v0.27.0's fix for the distro Vulkan WSI layer was clobbered by the session script, so games ran -- 2.54.0