Compare commits

..
Author SHA1 Message Date
enricobuehler c12476736d docs(changelog): v0.28.0 was tagged, so these fixes belong in a v0.28.1 section
ci / rust (pull_request) Successful in 4m44s
ci / bun-nix (pull_request) Successful in 28s
ci / rust-arm64 (pull_request) Successful in 4m32s
ci / docs-site (pull_request) Successful in 1m30s
ci / web (pull_request) Successful in 5m54s
nix / flake (pull_request) Successful in 15m13s
android / android (pull_request) Failing after 1m10s
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).
2026-08-13 20:40:02 +02:00
enricobuehler 5bcee83c34 test: /bin/true and /bin/false do not exist on NixOS
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.
2026-08-13 20:30:33 +02:00
enricobuehler 4ebe7d1185 fix(host): the plugin runner was located by FHS path only, so NixOS never found it
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.
2026-08-13 20:27:26 +02:00
16 changed files with 404 additions and 298 deletions
+42
View File
@@ -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.
@@ -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"), &params(), 40_000_000).unwrap_err();
let err =
spawn_link(&a_binary_that_exits_immediately(), &params(), 40_000_000).unwrap_err();
let text = format!("{err:#}");
assert!(
text.contains("handshake"),
+2 -6
View File
@@ -185,12 +185,8 @@ pub enum MaxLevelIdc {
H265(hh::StdVideoH265LevelIdc),
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel`. Unlike the other two this code
/// space is the BITSTREAM's own: `StdVideoAV1Level` is index-coded exactly like
/// AV1's `seq_level_idx` (2.0 = 0, 2.1 = 1, … 7.3 = 23).
///
/// ⚠ Only over 0…23. `seq_level_idx` is 5 bits, and 31 is Annex A's "maximum
/// parameters" sentinel — no level constraint — which outranks even a device
/// reporting the enum's top value. The AV1 gate therefore treats a stream above
/// this ceiling as advisory instead of refusing it (`VkAv1Decoder::ensure_state`).
/// AV1's `seq_level_idx` (2.0 = 0, 2.1 = 1, … 7.3 = 23), so the decoder's gate
/// compares the sequence header's value against it directly.
Av1(hh::StdVideoAV1Level),
}
+3 -10
View File
@@ -211,16 +211,9 @@ pub struct RawAv1Caps {
pub max_coded_extent: vk::Extent2D,
pub max_dpb_slots: u32,
pub max_active_reference_pictures: u32,
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel` (index-coded Std level — the same
/// numbering as the bitstream's `seq_level_idx` OVER 0…23, which is the whole
/// range `StdVideoAV1Level` enumerates).
///
/// ⚠ That correspondence does not extend to the rest of the bitstream field.
/// `seq_level_idx` is 5 bits: 24…30 are reserved and 31 is Annex A's "maximum
/// parameters" sentinel — "not constrained to a level" — which has no Std code
/// point and is NOT an ordering above 7.3. The decoder's gate therefore treats
/// a stream above this ceiling as advisory rather than comparing it as a level
/// (`VkAv1Decoder::ensure_state`).
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel` (index-coded Std level — the
/// SAME numbering as the bitstream's `seq_level_idx`, which is what makes the
/// decoder's level gate a plain comparison).
pub max_level: hh::StdVideoAV1Level,
/// `VkVideoCapabilitiesKHR::stdHeaderVersion` — session creation echoes it back.
pub std_header_version: vk::ExtensionProperties,
+15 -81
View File
@@ -100,7 +100,6 @@ use pf_bitstream::av1::NUM_REF_SLOTS;
use pf_bitstream::h264::DisplayCrop;
use tracing::debug;
use tracing::trace;
use tracing::warn;
use crate::caps::DecodeCaps;
use crate::caps::DecodeProfile;
@@ -689,10 +688,6 @@ pub struct VkAv1Decoder {
/// through a temporal unit, which is why the skip is per FRAME while the error
/// is per ACCESS UNIT.
awaiting_key: bool,
/// One-shot latch for the over-declared-level warning, so a stream whose
/// sequence header sits above the device ceiling says so once per decoder
/// rather than once per access unit (`ensure_state` runs per AU).
level_advisory_warned: bool,
}
impl VkAv1Decoder {
@@ -733,7 +728,6 @@ impl VkAv1Decoder {
device_lost: false,
recovery: RecoveryLatch::default(),
awaiting_key: false,
level_advisory_warned: false,
})
}
@@ -751,10 +745,8 @@ impl VkAv1Decoder {
///
/// The negotiated facts are a HINT (the in-band sequence header is
/// authoritative), so this is deliberately not a promise that decode will
/// succeed: a coded extent outside the caps, a DPB deeper than the device
/// allows, and a sequence header that disagrees with the Welcome all still
/// surface at the first AU. The declared LEVEL is not among them — it is
/// advisory, and `ensure_state` only warns on it.
/// succeed: the level ceiling and a sequence header that disagrees with the
/// Welcome still surface at the first AU.
pub fn probe_stream_support(
&self,
chroma_format_idc: u8,
@@ -1486,9 +1478,8 @@ impl VkAv1Decoder {
self.flush();
}
/// Session/caps for THIS plan exist and match its extent + profile. A declared
/// level above the device ceiling warns once and proceeds — see the gate below
/// for why an AV1 `seq_level_idx` is advisory and 31 is not even a level.
/// Session/caps for THIS plan exist and match its extent + profile, and the
/// stream sits inside the device's level ceiling.
fn ensure_state(&mut self, plan: &AuPlan) -> Result<(), VkDecodeError> {
let key = profile_key_for(plan)?;
if self.caps.as_ref().map(|(k, _)| *k) != Some(key) {
@@ -1500,39 +1491,17 @@ impl VkAv1Decoder {
unsafe { query_av1_caps(&self.dev, key) }.map_err(|r| caps_query_error(r, key))?;
self.caps = Some((key, derive_caps_av1(&raw, wanted)?));
}
// The declared level vs the device ceiling: a DECLARED level above `maxLevel`
// is NOT a refusal, for the reason `VkH265Decoder::ensure_state` spells out —
// the level is a CLAIM, and the stream's real demands are enforced where they
// are physical facts (coded extent and DPB depth, checked in `rebuild_state`).
//
// AV1 makes the point sharper than H.265 did. `seq_level_idx` is a 5-bit
// field; Annex A defines 0…23 (levels 2.0…7.3) and reserves 24…30, but **31 is
// the "maximum parameters" level — the spec's own way of saying the bitstream
// is not constrained to any level at all**. `StdVideoAV1Level` has no code
// point for it (it stops at 7.3 = 23), so the index-coded comparison that
// holds across 0…23 is meaningless against 31: the sentinel is not a level
// and 31 > 23 is not "too demanding". Real-time encoders emit it as a matter
// of course — a 2026-08-13 field report (RTX 5060 client, 4K120) had EVERY
// AV1 session demote to D3D11VA on "stream level (seq_level_idx 31) above the
// device's maxLevel (AV1 Std level 23)" while the same hardware decoded the
// stream trivially. We never write an AV1 level on any host encode path, so
// whatever the vendor defaults to is what the client must accept.
//
// Unlike H.265 there is nothing to clamp: `StdVideoAV1SequenceHeader` carries
// no level field (see `params_av1`), so the declaration never reaches the
// driver and cannot be invalid usage. Warn once, proceed.
// The level gate. AV1's `StdVideoAV1Level` is index-coded exactly like the
// bitstream's `seq_level_idx` (2.0 = 0 … 7.3 = 23) and ascends with the
// level, so this is a plain comparison — of AV1 code points against an AV1
// ceiling, the pairing `MaxLevelIdc`'s tag exists to keep honest.
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
let stream_level = u32::from(stream_level_idx(plan));
if stream_level > caps_max_level.code_point() && !self.level_advisory_warned {
self.level_advisory_warned = true;
warn!(
stream_level,
ceiling = %caps_max_level,
"stream declares an AV1 level above the device ceiling — the declared \
level is advisory (seq_level_idx 31 means \"maximum parameters\", and \
encoders over-declare); proceeding, since the level never reaches the \
driver"
);
if stream_level > caps_max_level.code_point() {
return Err(VkDecodeError::Unsupported(format!(
"stream level (seq_level_idx {stream_level}) above the device's \
maxLevel ({caps_max_level})"
)));
}
let coded = coded_extent(plan);
match &self.state {
@@ -2938,45 +2907,10 @@ mod tests {
assert_eq!(key.output_format(), Some(crate::caps::NV12));
assert!(!key.film_grain);
// The level gate reads operating point 0. This vector declares a real level,
// inside the Std range — the sentinel case is pinned separately below.
// The level gate reads operating point 0 and stays inside the Std range.
assert!(stream_level_idx(&plan) <= 23);
}
/// `seq_level_idx` 31 is Annex A's "maximum parameters" — "not constrained to a
/// level" — not a level above 7.3, and `StdVideoAV1Level` has no code point for
/// it. Comparing it as an ordinary level is what demoted every AV1 session on a
/// 2026-08-13 field report (RTX 5060, 4K120): `maxLevel` came back 23 (7.3, the
/// device's own maximum) and 31 > 23 refused a stream the hardware decodes fine.
///
/// This pins the ARITHMETIC that made the refusal look reasonable, so nobody
/// restores the gate by reading `31 > 23` as "too demanding":
#[test]
fn the_av1_max_parameters_sentinel_is_not_a_level_above_the_ceiling() {
// The ceiling as the gate reads it, on a device that decodes everything the
// Std enum can name — 7.3, the top code point there is.
let ceiling = crate::caps::MaxLevelIdc::Av1(hh::StdVideoAV1Level_STD_VIDEO_AV1_LEVEL_7_3);
assert_eq!(ceiling.code_point(), 23, "the Std enum's top code point");
// Every `seq_level_idx` the Std enum names compares sanely against it…
for idx in 0..=ceiling.code_point() {
assert!(idx <= ceiling.code_point());
}
// …and everything above is OUTSIDE that code space, not above the ceiling:
// 24…30 are reserved and 31 is "maximum parameters". A maxed-out device
// cannot satisfy the comparison, which is why it is not a capability test.
for idx in (ceiling.code_point() + 1)..=31 {
assert!(
idx > ceiling.code_point(),
"seq_level_idx {idx} is outside the Std range, not a more demanding level"
);
}
// The field report's exact pairing, kept legible: 31 against a ceiling of 23.
assert!(31 > ceiling.code_point());
assert_eq!(format!("{ceiling}"), "AV1 Std level 23");
}
#[test]
fn only_a_decoded_key_frame_ends_the_wait_for_one() {
let mut planner = Av1Planner::new();
@@ -3017,7 +2951,7 @@ mod tests {
/// `PlanError::AwaitingIdr`, and the reason [`VkAv1Decoder::awaiting_key`]'s
/// docs carry: a clean `Ok(None)` resets the consumer's demotion streak once
/// per frame, so a rung whose every key frame fails (film grain on a device
/// without the grain profile; a coded extent outside the caps; a sequence header
/// without the grain profile; a level above `maxLevelIdc`; a sequence header
/// disagreeing with the negotiation) would never demote and the session would
/// hold a frozen screen with a clean bill of health.
///
@@ -47,15 +47,7 @@ pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
/// Minimum spacing between jump-to-live events, so a bottleneck that instantly rebuilds the queue (a
/// link/consumer that can't sustain the bitrate at all) degrades into a periodic skip + a logged
/// warning instead of a continuous flush/keyframe storm.
///
/// **Public because the HOST needs it to read its own logs.** Each jump-to-live sends a keyframe
/// request, so a client that cannot sustain the rate asks for one at exactly this spacing,
/// forever — and the host's recovery-cadence detector saw that perfect periodicity and blamed a
/// periodic *display* disturbance (2026-08-13 field log: `period_s=2.0`, three subsystems named,
/// none of them the cause). Perfect periodicity is the signature of a fixed software cooldown,
/// not of a physical disturbance. The host compares against this constant rather than a copy of
/// the number, so the two can never drift apart.
pub const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
pub(crate) const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
-1
View File
@@ -42,7 +42,6 @@ mod recovery;
mod rumble;
mod worker;
pub use self::frame_channel::FLUSH_COOLDOWN;
pub use self::planes::AudioPacket;
pub use self::probe::ProbeOutcome;
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
+6 -47
View File
@@ -62,17 +62,6 @@ pub struct PwAudioCapturer {
/// active). Toggled by open/[`drain`](AudioCapturer::drain) (claim) and
/// [`idle`](AudioCapturer::idle)/Drop (release).
claimed: bool,
/// Whether a session is currently CONSUMING this capturer, shared with the PipeWire
/// thread so the drop counter can tell "the encode thread fell behind" from "nobody is
/// reading". The capturer is host-lifetime and merely PARKED between sessions
/// ([`idle`](AudioCapturer::idle)), so without this the producer keeps filling the bounded
/// hand-off channel, every `try_send` fails once it is full, and the plane reports a 100 %
/// drop rate — warning that "the stream will click" when there is no stream. A 2026-08-13
/// field host log carried ten such warnings, up to `dropped_chunks=11251` (= 30 s × 375
/// chunks/s, i.e. every single chunk), each one straddling a session boundary and each one
/// meaningless. Distinct from `claimed`, which tracks the sink-routing claim and only
/// exists when the stream sink is enabled at all.
active: Arc<AtomicBool>,
}
impl PwAudioCapturer {
@@ -101,21 +90,10 @@ impl PwAudioCapturer {
// mode the sink node must exist before we claim the default to its name.
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
let thread_sink_name = sink_name.clone();
// Opens at session start (see the routing claim below), so the consumer is live from
// the first chunk.
let active = Arc::new(AtomicBool::new(true));
let thread_active = Arc::clone(&active);
thread::Builder::new()
.name("punktfunk-pw-audio".into())
.spawn(move || {
if let Err(e) = pw_thread(
tx,
quit_rx,
channels,
thread_sink_name,
ready_tx,
thread_active,
) {
if let Err(e) = pw_thread(tx, quit_rx, channels, thread_sink_name, ready_tx) {
tracing::error!(error = %format!("{e:#}"), "pipewire audio thread failed");
}
})
@@ -140,16 +118,12 @@ impl PwAudioCapturer {
quit: quit_tx,
sink_name,
claimed,
active,
})
}
}
impl Drop for PwAudioCapturer {
fn drop(&mut self) {
// The receiver dies with us; anything the producer still pushes is unwanted by
// definition, and it must not be reported as the encode thread falling behind.
self.active.store(false, Ordering::Relaxed);
if self.claimed {
self.claimed = false;
stream_sink::release();
@@ -183,15 +157,9 @@ impl AudioCapturer for PwAudioCapturer {
stream_sink::claim(name);
self.claimed = true;
}
// Ordered AFTER the backlog drain, so the producer never counts a drop against a
// channel this call is still emptying.
self.active.store(true, Ordering::Relaxed);
}
fn idle(&mut self) {
// Parked: from here the channel fills and stays full, and those drops are nobody's
// fault. See `PwAudioCapturer::active`.
self.active.store(false, Ordering::Relaxed);
if self.claimed {
self.claimed = false;
stream_sink::release();
@@ -676,7 +644,6 @@ fn pw_thread(
channels: u32,
sink_name: Option<String>,
ready: std::sync::mpsc::SyncSender<Result<()>>,
active: Arc<AtomicBool>,
) -> Result<()> {
use pipewire as pw;
use pw::{properties::properties, spa};
@@ -768,9 +735,6 @@ fn pw_thread(
/// never again — the one number that identifies a clamped quantum, invisible on every
/// subsequent open (including every reopen after a device change).
reported_quantum: bool,
/// Shared with the capturer — see [`PwAudioCapturer::active`]. Read on every
/// failed hand-off to keep parked-capturer backpressure out of the drop count.
active: Arc<AtomicBool>,
}
let ud = CapUd {
tx,
@@ -778,7 +742,6 @@ fn pw_thread(
stats: Default::default(),
last_stats: std::time::Instant::now(),
reported_quantum: false,
active,
};
let _listener = stream
.add_local_listener_with_user_data(ud)
@@ -881,15 +844,11 @@ fn pw_thread(
samples.push(f32::from_le_bytes(b));
}
ud.stats.observe(&samples, ud.channels);
// Non-blocking and lossy, as before — but COUNTED, and only while a session
// is actually reading. A full channel under a LIVE consumer means the encode
// thread is not keeping up, and because the encoder simply concatenates
// across the hole every dropped chunk is a click AND a permanent shift of
// everything after it. A full channel under a PARKED capturer means nothing
// at all: the capturer is host-lifetime, so between sessions the channel
// fills once and then refuses everything, which counted as a 100 % drop rate
// and warned about a stream that did not exist (`PwAudioCapturer::active`).
if ud.tx.try_send(samples).is_err() && ud.active.load(Ordering::Relaxed) {
// Non-blocking and lossy, as before — but COUNTED. A full channel means the
// encode thread is not keeping up, and because the encoder simply
// concatenates across the hole every dropped chunk is a click AND a
// permanent shift of everything after it.
if ud.tx.try_send(samples).is_err() {
ud.stats.dropped_chunks += 1;
}
if ud.last_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY {
@@ -43,15 +43,6 @@ pub struct WasapiLoopbackCapturer {
channels: u32,
stop: Arc<AtomicBool>,
join: Option<JoinHandle<()>>,
/// Whether a session is currently CONSUMING this capturer, shared with the capture thread
/// so the drop counter can tell "the encode thread fell behind" from "nobody is reading".
/// The native/gamestream planes park a capturer between sessions
/// ([`idle`](AudioCapturer::idle)) instead of dropping it, and the hand-off channel is
/// bounded — so without this the thread fills it once, then counts every subsequent chunk
/// as a drop and warns that "the stream will click" with no stream to click. Proven on the
/// Linux twin by a 2026-08-13 field log (100 % drop rate across session gaps); the parking
/// call sites are platform-independent, so this half had the same defect.
active: Arc<AtomicBool>,
}
impl WasapiLoopbackCapturer {
@@ -67,13 +58,10 @@ impl WasapiLoopbackCapturer {
// rather than a silent dead thread.
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
let stop_t = stop.clone();
// Opens at session start, so the consumer is live from the first chunk.
let active = Arc::new(AtomicBool::new(true));
let active_t = active.clone();
let join = thread::Builder::new()
.name("punktfunk-wasapi-audio".into())
.spawn(move || {
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels, active_t) {
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels) {
tracing::error!(error = %format!("{e:#}"), "wasapi loopback thread failed");
}
})
@@ -88,7 +76,6 @@ impl WasapiLoopbackCapturer {
channels,
stop,
join: Some(join),
active,
})
}
Ok(Err(e)) => Err(e),
@@ -105,9 +92,6 @@ impl WasapiLoopbackCapturer {
impl Drop for WasapiLoopbackCapturer {
fn drop(&mut self) {
// The receiver dies with us; anything the thread still pushes is unwanted by
// definition, and must not be reported as the encode thread falling behind.
self.active.store(false, Ordering::Relaxed);
self.stop.store(true, Ordering::SeqCst);
if let Some(j) = self.join.take() {
let _ = j.join();
@@ -130,14 +114,6 @@ impl AudioCapturer for WasapiLoopbackCapturer {
}
fn drain(&mut self) {
while self.chunks.try_recv().is_ok() {}
// Ordered AFTER the backlog drain, so the capture thread never counts a drop against a
// channel this call is still emptying.
self.active.store(true, Ordering::Relaxed);
}
fn idle(&mut self) {
// Parked: from here the channel fills and stays full, and those drops are nobody's
// fault. See [`WasapiLoopbackCapturer::active`].
self.active.store(false, Ordering::Relaxed);
}
}
@@ -191,7 +167,6 @@ fn capture_thread(
stop: Arc<AtomicBool>,
ready: SyncSender<Result<()>>,
channels: u32,
active: Arc<AtomicBool>,
) -> Result<()> {
// COM must be initialized on THIS thread (MTA), before any device call.
if let Err(e) = wasapi::initialize_mta()
@@ -217,7 +192,7 @@ fn capture_thread(
// is said once per topology — the field log drowned in 256+ copies of the same line.
let mut unsat_logged: Option<u64> = None;
while !stop.load(Ordering::Relaxed) {
match capture_once(&tx, &stop, &mut ready, channels, mode, &active) {
match capture_once(&tx, &stop, &mut ready, channels, mode) {
Ok(Next::Stopped) => break,
Ok(Next::Reopen(m)) => {
mode = m;
@@ -382,7 +357,6 @@ fn capture_once(
ready: &mut Option<SyncSender<Result<()>>>,
channels: u32,
mode: TargetMode,
active: &AtomicBool,
) -> Result<Next> {
// Interleaved f32: channels * 4 bytes per frame.
let block_align = channels as usize * 4;
@@ -637,14 +611,10 @@ fn capture_once(
samples.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
}
stats.observe(&samples, channels);
// Non-blocking, lossy — same discipline as PipeWire. COUNTED, and only while a
// session is actually reading: a full channel under a LIVE consumer means the encode
// thread is not keeping up, and every dropped chunk is a click plus a permanent
// shift of everything after it. A full channel under a PARKED capturer means nothing
// — the planes park capturers between sessions rather than dropping them, so the
// channel fills once and then refuses everything
// ([`WasapiLoopbackCapturer::active`]).
if tx.try_send(samples).is_err() && active.load(Ordering::Relaxed) {
// Non-blocking, lossy — same discipline as PipeWire. Now COUNTED: a full channel
// means the encode thread is not keeping up, and every dropped chunk is a click plus
// a permanent shift of everything after it.
if tx.try_send(samples).is_err() {
stats.dropped_chunks += 1;
}
}
+3 -1
View File
@@ -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(
+8 -66
View File
@@ -2722,35 +2722,14 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
last_forced_idr = Some(now);
rfi_echo_swallowed = 0; // the IDR resets the episode — echoes of IT coalesce via the cooldown
if let Some(period) = recovery_cadence.note(now) {
// A period that lands on the CLIENT's jump-to-live cooldown is not evidence
// of a periodic disturbance here at all — it is the client shedding a
// standing receive queue, which it is rate-limited to do exactly this often
// (`punktfunk_core::client::FLUSH_COOLDOWN`), so the cadence is a property of
// our own backpressure code rather than of anything physical. Naming display
// churn there sent a 2026-08-13 field investigation at three innocent
// subsystems while the real chain was: client refused the codec → demoted to
// a slower decode rung → could not sustain the rate → standing queue.
// Perfect periodicity argues FOR a software cooldown, not against it.
if matches_client_flush_cadence(period) {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
"client keyframe recoveries match the client's jump-to-live cooldown \
the CLIENT cannot sustain the stream and is shedding a standing \
receive queue (check its log for 'receive backlog stopped draining' \
with queue_depth, and for a decode rung that demoted); a slower \
decode path or a link below the bitrate does this, and it is NOT a \
host display disturbance"
);
} else {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
"client keyframe recoveries are METRONOMIC — a periodic host/display \
disturbance (display-topology churn, display-poller software, \
virtual-display timing) is the likely cause, not random network \
loss; correlate with 'slow display-descriptor poll' / 'display \
descriptor changed' / 'IDD-push capture stall' lines"
);
}
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
"client keyframe recoveries are METRONOMIC — a periodic host/display \
disturbance (display-topology churn, display-poller software, \
virtual-display timing) is the likely cause, not random network loss; \
correlate with 'slow display-descriptor poll' / 'display descriptor \
changed' / 'IDD-push capture stall' lines"
);
}
}
}
@@ -3806,23 +3785,6 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
Ok(())
}
/// Whether a measured keyframe-recovery period is the CLIENT's jump-to-live cooldown rather
/// than anything happening on this host.
///
/// Every jump-to-live sends a keyframe request and is rate-limited to one per
/// [`punktfunk_core::client::FLUSH_COOLDOWN`], so a client that simply cannot sustain the
/// stream asks at exactly that spacing for as long as it stays behind. The recovery-cadence
/// detector reads perfect periodicity as evidence of a periodic *disturbance*, which is
/// backwards here: a fixed software cooldown is the most periodic thing in the system.
///
/// ±10 % — wide enough for scheduling jitter and the request's network trip, narrow enough that
/// it cannot swallow the disturbance cadences the other branch exists to report (display-mode
/// churn and descriptor polls run at their own, unrelated periods).
fn matches_client_flush_cadence(period: std::time::Duration) -> bool {
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
period.abs_diff(flush) < flush / 10
}
/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval).
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
type Pipeline = (
@@ -4635,26 +4597,6 @@ fn build_pipeline(
mod tests {
use super::*;
/// The 2026-08-13 field log's exact reading — `period_s=2.0` — must be attributed to the
/// client's backlog shedding, not to a host display disturbance. The whole point of routing
/// on the shared constant is that this stays true if the cooldown is ever retuned, so the
/// test derives its cases from `FLUSH_COOLDOWN` instead of hardcoding two seconds.
#[test]
fn a_recovery_cadence_on_the_clients_cooldown_is_not_blamed_on_the_display() {
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
assert!(matches_client_flush_cadence(flush), "the field reading");
// Scheduling jitter and the request's trip across the link stay inside the band.
assert!(matches_client_flush_cadence(flush + flush / 20));
assert!(matches_client_flush_cadence(flush - flush / 20));
// Cadences that are NOT the cooldown still reach the display-disturbance branch — the
// band must not be so wide that it swallows them.
assert!(!matches_client_flush_cadence(flush / 2));
assert!(!matches_client_flush_cadence(flush * 2));
assert!(!matches_client_flush_cadence(flush + flush / 5));
assert!(!matches_client_flush_cadence(std::time::Duration::ZERO));
}
#[test]
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
const DEGRADE: u32 = 10;
+248 -34
View File
@@ -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<String>)> {
}
#[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<String>)> {
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<String>)> {
(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<PathBuf>) -> 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}");
}
}
}
+1
View File
@@ -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
+10 -1
View File
@@ -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
+31 -2
View File
@@ -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 -----------------------------
{
+9 -1
View File
@@ -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