Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c12476736d | ||
|
|
5bcee83c34 | ||
|
|
4ebe7d1185 |
@@ -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"), ¶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"),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -----------------------------
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user