chore: consolidate parallel-session WIP (HOLD — do not push)

Local snapshot of intermingled in-flight work, committed to unblock the encode
refactor (a clean ffmpeg_win.rs for the vbv-dedup follow-on). These hunks span
the same files and can't be cleanly split here; the commit bundles three
distinct workstreams that each belong in their own PR:

  - logging rework (~43 files: level re-tiering, structured fields, `?e`,
    hot-path flood latches)
  - conflicting-host detection (detect.rs + detect/{linux,windows}.rs + wiring
    in main.rs/mgmt.rs/Cargo.toml/docs/packaging)
  - standby-sink DWM-stall attribution (windows/display_events.rs + capture/
    vdisplay wiring)

NOT verified as a combination. NOT to be pushed until the refactor is done and
these are re-verified and reorganized into their proper per-workstream PRs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 12:42:53 +02:00
parent d466e3e2b2
commit 11045a0f70
62 changed files with 1595 additions and 214 deletions
+5
View File
@@ -38,3 +38,8 @@ CLAUDE.md
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
.flatpak-repo/
.flatpak-build/
# Nix build outputs (flake.nix) — `nix build` result symlinks + direnv cache. flake.lock IS tracked.
/result
/result-*
.direnv/
+6 -2
View File
@@ -161,7 +161,7 @@ fn run_sync(
return;
}
log::info!(
"decode: HEVC decoder started at {}x{}",
"decode: {mime} decoder started at {}x{}",
mode.width,
mode.height
);
@@ -815,7 +815,11 @@ fn run_async(
})),
on_error: Some(Box::new(move |e, code, _detail| {
let fatal = !code.is_recoverable() && !code.is_transient();
log::warn!("decode: codec error {e:?} (fatal={fatal})");
if fatal {
log::error!("decode: fatal codec error — stream will stop: {e:?}");
} else {
log::warn!("decode: codec error {e:?} (recoverable)");
}
let _ = err_tx.send(DecodeEvent::Error { fatal });
})),
};
+2 -2
View File
@@ -60,7 +60,7 @@ impl AudioPlayer {
.spawn(move || {
if let Err(e) = render_thread(pcm_rx, recycle_tx, stop_t, ready_tx, channels as u8)
{
tracing::warn!(error = format!("{e:#}"), "audio playback thread ended");
tracing::warn!(error = %format!("{e:#}"), "audio playback thread ended");
}
})
.context("spawn audio thread")?;
@@ -232,7 +232,7 @@ impl MicStreamer {
.name("punktfunk-mic".into())
.spawn(move || {
if let Err(e) = mic_thread(&connector, stop_t) {
tracing::warn!(error = format!("{e:#}"), "mic uplink thread ended");
tracing::warn!(error = %format!("{e:#}"), "mic uplink thread ended");
}
})
.context("spawn mic thread")?;
+1 -1
View File
@@ -1493,7 +1493,7 @@ impl Worker {
Err(e) => {
tracing::warn!(pad = slot.index, low, high, error = %e, "rumble: SDL set_rumble failed")
}
Ok(()) => tracing::debug!(pad = slot.index, low, high, "rumble: rendered"),
Ok(()) => tracing::trace!(pad = slot.index, low, high, "rumble: rendered"),
}
slot.rumble.last = (low, high);
slot.rumble.last_at = Some(Instant::now());
+2 -2
View File
@@ -222,7 +222,7 @@ fn pump(
preferred = punktfunk_core::quic::CODEC_PYROWAVE;
} else {
tracing::warn!(
"PUNKTFUNK_PREFER_PYROWAVE=1 but the presenter device failed the pyrowave probe — keeping the normal codec preference"
"PUNKTFUNK_PREFER_PYROWAVE=1 but the presenter device failed the pyrowave probe — keeping the normal codec preference"
);
}
}
@@ -791,7 +791,7 @@ fn spawn_audio(
buf.extend_from_slice(&pcm[..n]);
player.push(buf);
}
Err(e) => tracing::debug!(error = %e, "opus decode"),
Err(e) => tracing::debug!(error = %e, "opus decode failed"),
}
}
Err(PunktfunkError::NoFrame) => {}
+5 -5
View File
@@ -510,7 +510,7 @@ impl Decoder {
if choice == "vaapi" {
return Err(e.context("PUNKTFUNK_DECODER=vaapi but VAAPI failed"));
}
tracing::info!(reason = %e, "VAAPI unavailable — software decode");
tracing::warn!(error = %e, "VAAPI unavailable — falling back to software decode");
}
}
}
@@ -695,8 +695,8 @@ impl Decoder {
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
self.vaapi_fails = 0;
} else {
tracing::warn!(error = %e,
"{which} decode error — requesting keyframe, keeping hardware decode");
tracing::debug!(backend = which, error = %e,
"decode error — requesting keyframe, keeping hardware decode");
}
Ok(None)
}
@@ -1653,7 +1653,7 @@ unsafe extern "C" fn pick_vulkan(
&mut fr,
);
if r < 0 || fr.is_null() {
tracing::warn!("avcodec_get_hw_frames_parameters(VULKAN) failed ({r})");
tracing::warn!(code = r, "avcodec_get_hw_frames_parameters(VULKAN) failed");
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
}
let fc = (*fr).data as *mut ffi::AVHWFramesContext;
@@ -1665,7 +1665,7 @@ unsafe extern "C" fn pick_vulkan(
as _;
let r = ffi::av_hwframe_ctx_init(fr);
if r < 0 {
tracing::warn!("av_hwframe_ctx_init(VULKAN) failed ({r})");
tracing::warn!(code = r, "av_hwframe_ctx_init(VULKAN) failed");
let mut fr = fr;
ffi::av_buffer_unref(&mut fr);
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
+4 -3
View File
@@ -537,7 +537,8 @@ impl PyroWaveDecoder {
let fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
tracing::info!(
mode = %format!("{width}x{height}"),
width,
height,
"PyroWave decoder open on the presenter's device (compute iDWT, BT.709 limited)"
);
Ok(PyroWaveDecoder {
@@ -602,8 +603,8 @@ impl PyroWaveDecoder {
});
self.next = 0;
tracing::info!(
from = %format!("{}x{}", self.width, self.height),
to = %format!("{width}x{height}"),
from = %format_args!("{}x{}", self.width, self.height),
to = %format_args!("{width}x{height}"),
"PyroWave decoder rebuilt for mid-stream resize"
);
self.width = width;
+1 -1
View File
@@ -101,7 +101,7 @@ pub fn import(
// EGL could leave an INVALID modifier to the driver's implied choice; explicit-
// modifier images can't — LINEAR is the only honest guess (debug-visible if wrong).
let modifier = if frame.modifier == DRM_FORMAT_MOD_INVALID {
tracing::debug!("dmabuf carried no explicit modifier — importing as LINEAR");
tracing::trace!("dmabuf carried no explicit modifier — importing as LINEAR");
DRM_FORMAT_MOD_LINEAR
} else {
frame.modifier
+19 -3
View File
@@ -184,6 +184,11 @@ struct StreamState {
// Hardware-path health: a failure streak (or a device with no import support at
// all) demotes the decoder to software via the shared flag — once per session.
dmabuf_demoted: bool,
/// PyroWave present has no demote rung (nothing else decodes the codec), so a
/// persistent non-device-lost present failure would warn on every frame. Latch it:
/// warn on the first failure of a streak, then stay quiet until a present succeeds.
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
pyro_present_warned: bool,
hw_fails: u32,
/// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
osd_text: String,
@@ -255,6 +260,8 @@ impl StreamState {
win_start: Instant::now(),
presented: PresentedWindow::default(),
dmabuf_demoted: false,
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
pyro_present_warned: false,
hw_fails: 0,
osd_text: String::new(),
last_stats: None,
@@ -542,7 +549,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
fullscreen = !fullscreen;
tracing::debug!(fullscreen, "fullscreen toggle");
if let Err(e) = window.set_fullscreen(fullscreen) {
tracing::warn!(error = %e, "fullscreen toggle");
tracing::warn!(error = %e, fullscreen, "failed to toggle fullscreen");
}
continue;
}
@@ -985,13 +992,22 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
FrameInput::PyroWave(f),
overlay_frame.as_ref(),
) {
Ok(p) => p,
Ok(p) => {
st.pyro_present_warned = false;
p
}
Err(e) => {
if device_lost(&e) {
return Err(e)
.context("GPU device lost — the session cannot continue");
}
tracing::warn!(error = %format!("{e:#}"), "pyrowave present failed");
if !st.pyro_present_warned {
st.pyro_present_warned = true;
tracing::warn!(
error = %format!("{e:#}"),
"pyrowave present failed — suppressing repeats until it recovers"
);
}
false
}
}
+5 -1
View File
@@ -1941,7 +1941,11 @@ async fn worker_main(args: WorkerArgs) {
ResyncStep::Idle => {}
}
} else {
tracing::warn!("unknown control message — ignoring");
tracing::warn!(
tag = ?msg.first(),
len = msg.len(),
"unknown control message — ignoring"
);
}
}
}
+1 -2
View File
@@ -230,8 +230,7 @@ mod uso {
STATE.store(if off { 2 } else { 1 }, Ordering::Relaxed);
tracing::info!(
enabled = !off,
"Windows UDP Send Offload (USO): {} (the 1 Gbps+ send lever; PUNKTFUNK_GSO=0 disables)",
if off { "off" } else { "on" }
"Windows UDP Send Offload (USO) resolved (the 1 Gbps+ send lever; PUNKTFUNK_GSO=0 disables)"
);
!off
}
+3
View File
@@ -217,6 +217,9 @@ windows = { version = "0.62", features = [
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
"Win32_System_Diagnostics_Debug",
"Win32_System_Kernel",
# CreateToolhelp32Snapshot/Process32*W — the conflicting-streaming-host process scan
# (src/detect/windows.rs): is Sunshine/Apollo/... running alongside us?
"Win32_System_Diagnostics_ToolHelp",
] }
# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
+6 -3
View File
@@ -156,7 +156,10 @@ impl PwMicSource {
.name("punktfunk-pw-mic".into())
.spawn(move || {
if let Err(e) = mic_pw_thread(pcm_rx, quit_rx, channels, flush_t, ready_tx) {
tracing::error!(error = %format!("{e:#}"), "pipewire virtual-mic thread failed");
// Reaching here is always a setup/open failure (once the mainloop runs it exits
// Ok) — and it was already reported to the pump via the ready handshake, which
// owns the throttled operator-facing warn. Keep only a debug breadcrumb.
tracing::debug!(error = %format!("{e:#}"), "pipewire virtual-mic setup failed — pump will back off and retry");
}
// Whether a clean quit or a daemon death: this instance is done — the pump reopens.
alive_t.store(false, Ordering::Release);
@@ -322,7 +325,7 @@ fn mic_pw_thread(
.state_changed({
let mainloop = mainloop.clone();
move |_s, _ud, old, new| {
tracing::info!(?old, ?new, "pipewire virtual-mic stream state");
tracing::debug!(?old, ?new, "pipewire virtual-mic stream state");
// A stream error is unrecoverable for this instance — exit so the pump reopens.
if matches!(new, pw::stream::StreamState::Error(_)) {
mainloop.quit();
@@ -522,7 +525,7 @@ fn pw_thread(
let _listener = stream
.add_local_listener_with_user_data(tx)
.state_changed(|_s, _ud, old, new| {
tracing::info!(?old, ?new, "pipewire audio stream state");
tracing::debug!(?old, ?new, "pipewire audio stream state");
})
.param_changed(|_stream, _tx, id, param| {
let Some(param) = param else { return };
@@ -39,7 +39,7 @@ impl WasapiLoopbackCapturer {
.name("punktfunk-wasapi-audio".into())
.spawn(move || {
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels) {
tracing::error!(error = format!("{e:#}"), "wasapi loopback thread failed");
tracing::error!(error = %format!("{e:#}"), "wasapi loopback thread failed");
}
})
.context("spawn wasapi audio thread")?;
+15 -21
View File
@@ -650,8 +650,6 @@ mod pipewire {
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
/// so the GPU encoder re-uploads its cursor texture only on change.
serial: u64,
/// One-shot guard for the "cursor present but this frame is zero-copy" notice.
warned_zerocopy: bool,
}
impl CursorState {
@@ -1174,22 +1172,6 @@ mod pipewire {
if ud.broken.load(Ordering::Relaxed) {
return;
}
// Cursor-as-metadata only reaches the frame on the CPU de-pad path below (a small
// straight-alpha blit). The zero-copy paths hand a GPU-resident buffer straight to the
// encoder, so the cached cursor can't be composited here — that needs a GPU blit in the
// encoder (follow-up). Note it once, so a gamescope host (zero-copy by default) shows in
// the logs that the metadata IS arriving even while the overlay isn't drawn yet.
if ud.cursor.visible
&& !ud.cursor.warned_zerocopy
&& (ud.importer.is_some() || ud.vaapi_passthrough)
{
ud.cursor.warned_zerocopy = true;
tracing::warn!(
"cursor metadata received, but frames are delivered zero-copy (GPU-resident) — \
the cursor overlay is composited only on the CPU capture path today; GPU-path \
compositing (Vulkan/CUDA/VAAPI encode) is a follow-up"
);
}
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
@@ -1241,7 +1223,7 @@ mod pipewire {
std::sync::atomic::AtomicBool::new(true);
if F2.swap(false, Ordering::Relaxed) {
tracing::warn!(
error = %format!("{e}"),
error = %e,
"dmabuf EXPORT_SYNC_FILE failed — no implicit-fence sync; NVIDIA \
zero-copy may show stale frames (no producer explicit sync)"
);
@@ -1915,7 +1897,15 @@ mod pipewire {
unsafe { stream.queue_raw_buffer(newest) };
}));
if outcome.is_err() {
tracing::error!("panic in pipewire process callback — frame dropped");
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad
// format) would fire this every frame, so power-of-two throttle it — enough to
// surface the fault without evicting the whole log ring.
static PANICS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
let n = PANICS.fetch_add(1, Ordering::Relaxed) + 1;
if n.is_power_of_two() {
tracing::error!(count = n, "panic in pipewire process callback — frame dropped");
}
}
})
.register()
@@ -1930,7 +1920,11 @@ mod pipewire {
// Request raw video in any encoder-mappable layout, any size/framerate.
let obj = if let Some((fw, fh)) = fixed_pod {
tracing::info!(fw, fh, "PW DEBUG: offering fixed BGRx pod");
tracing::info!(
fw,
fh,
"pipewire: offering a fixed BGRx format pod (PUNKTFUNK_PW_FIXED_POD)"
);
pw::spa::pod::object!(
pw::spa::utils::SpaTypes::ObjectParamFormat,
pw::spa::param::ParamType::EnumFormat,
@@ -299,7 +299,7 @@ pub(crate) fn install_gpu_pref_hook() {
// 100% DuplicateOutput1 E_ACCESSDENIED is diagnosable instead of silent.
match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) {
Ok(()) => tracing::info!("DPI awareness set: PER_MONITOR_AWARE_V2"),
Err(e) => tracing::warn!(error = %format!("{e:?}"),
Err(e) => tracing::warn!(error = ?e,
"SetProcessDpiAwarenessContext failed (already set?) — DuplicateOutput1 may E_ACCESSDENIED"),
}
// 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). DuplicateOutput1 needs 2.
@@ -595,7 +595,7 @@ impl DescriptorPoller {
.map_err(|e| {
// Degraded, not fatal: the session streams, it just never follows a mid-session
// HDR flip / mode-set (seq stays 0 → the consumer sees no changes).
tracing::error!(error = %e, "IDD push: descriptor-poller thread failed to spawn");
tracing::warn!(error = %e, "IDD push: descriptor-poller thread failed to spawn — mid-session HDR/mode changes won't be followed");
})
.ok();
Self { snap, stop, thread }
@@ -753,6 +753,13 @@ pub struct IddPushCapturer {
/// during active flow and warns when they turn metronomic — the sole-virtual-display
/// periodic-stutter diagnostic.
stall_watch: StallWatch,
/// Stall↔OS-event correlation counters for the metronomic warn: how many stalls this session,
/// and how many had a coinciding [`crate::display_events`] event in their gap window — the
/// discriminator between "Windows re-enumerates a monitor each cycle" (devnode churn the
/// `pnp_disable_monitors` axis suppresses) and "the disturbance is below the OS" (GPU driver
/// servicing a standby sink / display-poller software).
stalls_seen: u32,
stalls_with_os_events: u32,
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
@@ -886,6 +893,9 @@ impl IddPushCapturer {
want_444: bool,
keepalive: Box<dyn Send>,
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
crate::display_events::spawn_once();
match Self::open_inner(target, preferred, client_10bit, want_444) {
Ok(mut me) => {
me._keepalive = keepalive;
@@ -1146,6 +1156,8 @@ impl IddPushCapturer {
last_liveness: Instant::now(),
last_kick: Instant::now(),
stall_watch: StallWatch::new(),
stalls_seen: 0,
stalls_with_os_events: 0,
out_ring: Vec::new(),
out_idx: 0,
video_conv: None,
@@ -1646,24 +1658,70 @@ impl IddPushCapturer {
// doesn't read as a DWM stall.
self.stall_watch.reset();
} else if let Some(stall) = self.stall_watch.note_fresh(now) {
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
let window = stall.gap + Duration::from_millis(300);
let events = now
.checked_sub(window)
.map(|from| crate::display_events::events_between(from, now))
.unwrap_or_default();
self.stalls_seen = self.stalls_seen.saturating_add(1);
if !events.is_empty() {
self.stalls_with_os_events = self.stalls_with_os_events.saturating_add(1);
}
// debug (not warn): a single hole also happens when content legitimately pauses;
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
// at debug level, and the web-console debug ring captures these.
tracing::debug!(
gap_ms = stall.gap.as_millis() as u64,
os_display_events = %crate::display_events::summarize(&events),
"IDD-push capture stall — the desktop was composing at speed, then DWM \
delivered no frame for the gap; the present path stalled below capture"
);
if let Some(period) = stall.metronomic {
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
"capture stalls are METRONOMIC — DWM stops composing the virtual display \
on a stable period, i.e. a periodic display-path disturbance BELOW \
capture (DWM present clock / GPU driver / display-poller software). \
Correlate with 'slow display-descriptor poll'; if that never fires, the \
disturbance is outside punktfunk try display topology=primary or \
extend (keep a physical output active), or a different refresh rate"
);
let suspects = crate::display_events::connected_inactive_externals();
let suspects = if suspects.is_empty() {
"none".to_string()
} else {
suspects.join(", ")
};
let correlated = format!("{}/{}", self.stalls_with_os_events, self.stalls_seen);
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
// cascade is OS-visible; otherwise the disturbance never surfaces above the
// driver. Different classes, different cures — say which one this box has.
if self.stalls_with_os_events * 2 >= self.stalls_seen {
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
"capture stalls are METRONOMIC and coincide with Windows monitor \
hot-plug/re-enumeration events a connected display (or its \
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
each time. Cures, best-first: that display's OSD 'auto input \
scan/detect' OFF (and on TVs: instant-on/quick-start + CEC off), \
unplug its cable at the GPU, an HPD-holding adapter/dummy plug, or \
keep it active while streaming; the pnp_disable_monitors policy axis \
suppresses the Windows-side reaction (see connected_inactive for the \
suspects)"
);
} else {
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
"capture stalls are METRONOMIC with NO coinciding OS display event — \
the disturbance is BELOW Windows: the GPU driver servicing a \
connected-but-asleep sink (standby HPD/DDC/link probing), \
display-poller software (the SteelSeries-GG/SignalRGB class \
correlate 'slow display-descriptor poll' lines), or the DWM present \
clock (try a different refresh rate). If connected_inactive lists a \
display, its standby probing is the prime suspect: unplug it at the \
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
streaming"
);
}
}
}
self.last_fresh = now; // feeds the driver-death watch
+342
View File
@@ -0,0 +1,342 @@
//! Conflicting game-streaming host detection.
//!
//! Punktfunk is one of a family of Moonlight-compatible desktop-streaming hosts. The others —
//! Sunshine and its many forks (Apollo, Vibeshine, Vibepollo, LuminalShine, …) — all impersonate
//! NVIDIA GameStream: they bind the **same** ports (47984/47989 nvhttp, 47998-48010 stream,
//! 47990 web UI — which is also our management API), advertise the **same** `_nvstream._tcp`
//! mDNS service, and frequently install a **conflicting virtual-display driver**. Running one of
//! them alongside Punktfunk is unsupported — the symptoms are `address already in use` bind
//! failures, pairing that silently fails, and capture/virtual-display glitches.
//!
//! This module proactively detects such a host (installed and/or running) so we can surface it as
//! early as possible: at host startup (a `warn!` into the log ring + tray/console summary) and via
//! the `detect-conflicts` subcommand the installers/support run.
//!
//! Detection is **fingerprint-first by name**: a small table of the known products (extend
//! [`KNOWN`] as new forks appear) matched against running processes, registered OS services/units,
//! and on-disk install markers. The platform back-ends (`detect/windows.rs`, `detect/linux.rs`)
//! provide the raw facts; the matching + rendering here is portable and unit-tested.
use std::sync::OnceLock;
#[cfg(target_os = "windows")]
#[path = "detect/windows.rs"]
mod platform;
#[cfg(target_os = "linux")]
#[path = "detect/linux.rs"]
mod platform;
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
mod platform {
//! The host only runs on Windows/Linux; the crate still compiles on macOS (dev) — nothing to
//! scan there.
pub fn running_processes() -> Vec<String> {
Vec::new()
}
pub fn static_evidence(_known: &super::Known) -> Vec<super::Evidence> {
Vec::new()
}
}
/// A known competing GameStream/Moonlight host.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Product {
Sunshine,
Apollo,
Vibeshine,
Vibepollo,
Luminalshine,
}
impl Product {
/// The name shown to the user.
pub fn label(self) -> &'static str {
match self {
Product::Sunshine => "Sunshine",
Product::Apollo => "Apollo",
Product::Vibeshine => "Vibeshine",
Product::Vibepollo => "Vibepollo",
Product::Luminalshine => "LuminalShine",
}
}
}
/// How a conflicting host was observed on this machine.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Evidence {
/// A matching process is running **right now** (process/executable basename).
Running { process: String },
/// An OS service / systemd unit for the product is registered (installed; may be stopped).
Service { name: String },
/// Installed on disk — a Program Files directory, a flatpak app id, or a binary on `PATH`.
Installed { at: String },
}
impl Evidence {
fn render(&self) -> String {
match self {
Evidence::Running { process } => format!("running now ({process})"),
Evidence::Service { name } => format!("service {name}"),
Evidence::Installed { at } => format!("installed at {at}"),
}
}
}
/// A detected conflicting host, with every piece of corroborating evidence found.
#[derive(Clone, Debug)]
pub struct Detection {
pub product: Product,
pub evidence: Vec<Evidence>,
}
impl Detection {
/// True when a matching process is live — the acute case (a guaranteed resource clash the
/// moment Punktfunk tries to bind its ports).
pub fn is_running(&self) -> bool {
self.evidence
.iter()
.any(|e| matches!(e, Evidence::Running { .. }))
}
/// A compact one-line label for the tray/console summary, e.g. `Sunshine (running)`.
pub fn label(&self) -> String {
if self.is_running() {
format!("{} (running)", self.product.label())
} else {
self.product.label().to_string()
}
}
}
/// One row of the known-conflicting-host table. Names are matched case-insensitively; process /
/// binary basenames are given **without** an extension (the platform code lowercases + strips
/// `.exe`). Extend this as new Sunshine forks appear — the runtime, the subcommand, and the
/// tray/console summary all key off this one list.
pub struct Known {
pub product: Product,
/// Process / executable basenames (lowercase, no extension) that identify this host.
pub processes: &'static [&'static str],
/// Windows service names (SCM keys under `HKLM\SYSTEM\CurrentControlSet\Services`).
pub win_services: &'static [&'static str],
/// Windows install-dir basenames under `%ProgramFiles%` / `%ProgramFiles(x86)%`.
pub win_dirs: &'static [&'static str],
/// Linux systemd unit basenames (without `.service`), checked in the standard unit dirs.
pub linux_units: &'static [&'static str],
/// Linux flatpak application ids.
pub flatpaks: &'static [&'static str],
}
/// The known Moonlight-compatible hosts that clash with Punktfunk. All are Sunshine or Sunshine
/// forks; add new forks here (one row) and every surface picks them up.
pub const KNOWN: &[Known] = &[
Known {
product: Product::Sunshine,
processes: &["sunshine"],
win_services: &["SunshineService"],
win_dirs: &["Sunshine"],
linux_units: &["sunshine"],
flatpaks: &["dev.lizardbyte.app.Sunshine"],
},
Known {
product: Product::Apollo,
processes: &["apollo"],
win_services: &["ApolloService"],
win_dirs: &["Apollo"],
linux_units: &["apollo"],
flatpaks: &["dev.lizardbyte.app.Apollo"],
},
Known {
product: Product::Vibeshine,
processes: &["vibeshine"],
win_services: &["VibeshineService"],
win_dirs: &["Vibeshine"],
linux_units: &["vibeshine"],
flatpaks: &[],
},
Known {
product: Product::Vibepollo,
processes: &["vibepollo"],
win_services: &["VibepolloService"],
win_dirs: &["Vibepollo"],
linux_units: &["vibepollo"],
flatpaks: &[],
},
Known {
product: Product::Luminalshine,
processes: &["luminalshine"],
win_services: &["LuminalShineService"],
win_dirs: &["LuminalShine"],
linux_units: &["luminalshine"],
flatpaks: &[],
},
];
/// Why running side-by-side breaks — shared by every surface (log, subcommand, installers).
pub const UNSUPPORTED_BLURB: &str =
"Running Punktfunk alongside another Moonlight-compatible host \
(Sunshine and its forks) is UNSUPPORTED: they bind the same GameStream ports (47984/47989, \
47998-48010), advertise the same _nvstream mDNS name, and often install a conflicting \
virtual-display driver. Expect \"address already in use\" errors, failed pairing, and capture \
glitches. Stop and uninstall the other host, or don't run them at the same time.";
/// Scan the machine for conflicting hosts. Portable; dispatches into the platform back-end. Does
/// real OS work (process enumeration, service/registry queries, filesystem stats) — cheap, but not
/// free, so prefer the cached [`init`]/[`snapshot`] for hot paths.
pub fn scan() -> Vec<Detection> {
let procs = platform::running_processes();
let mut out = Vec::new();
for known in KNOWN {
let mut evidence: Vec<Evidence> = Vec::new();
for p in &procs {
if known.processes.iter().any(|n| p == n) {
evidence.push(Evidence::Running { process: p.clone() });
}
}
evidence.extend(platform::static_evidence(known));
if !evidence.is_empty() {
out.push(Detection {
product: known.product,
evidence,
});
}
}
out
}
static SNAPSHOT: OnceLock<Vec<Detection>> = OnceLock::new();
/// Scan once and cache the result for the life of the process (the conflict set doesn't change at
/// streaming granularity — a snapshot taken at host bring-up is the right resolution and keeps the
/// per-poll `/local/summary` free). Returns the cached detections.
pub fn init() -> &'static [Detection] {
SNAPSHOT.get_or_init(scan)
}
/// The cached snapshot, or empty if [`init`] hasn't run. Non-scanning: safe to call from hot paths
/// and from tests without touching the OS.
pub fn snapshot() -> &'static [Detection] {
SNAPSHOT.get().map(Vec::as_slice).unwrap_or(&[])
}
/// Compact labels for the tray / web-console summary (e.g. `["Sunshine (running)", "Apollo"]`).
pub fn summary_labels(detections: &[Detection]) -> Vec<String> {
detections.iter().map(Detection::label).collect()
}
/// A full human-readable report: the blurb + one bullet per detected host with its evidence.
/// Empty string when nothing was detected (callers gate on `is_empty()`).
pub fn render_report(detections: &[Detection]) -> String {
if detections.is_empty() {
return String::new();
}
let mut s = String::from("Detected another game-streaming host on this machine.\n");
s.push_str(UNSUPPORTED_BLURB);
s.push_str("\n\nDetected:\n");
for d in detections {
let ev = d
.evidence
.iter()
.map(Evidence::render)
.collect::<Vec<_>>()
.join("; ");
s.push_str(&format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label()));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn det(product: Product, evidence: Vec<Evidence>) -> Detection {
Detection { product, evidence }
}
#[test]
fn empty_report_and_labels() {
assert!(render_report(&[]).is_empty());
assert!(summary_labels(&[]).is_empty());
}
#[test]
fn running_detection_is_flagged_and_labelled() {
let d = det(
Product::Sunshine,
vec![
Evidence::Running {
process: "sunshine".into(),
},
Evidence::Service {
name: "SunshineService".into(),
},
],
);
assert!(d.is_running());
assert_eq!(d.label(), "Sunshine (running)");
}
#[test]
fn installed_only_is_not_running() {
let d = det(
Product::Apollo,
vec![Evidence::Installed {
at: "C:\\Program Files\\Apollo".into(),
}],
);
assert!(!d.is_running());
assert_eq!(d.label(), "Apollo");
}
#[test]
fn report_lists_every_product_and_the_blurb() {
let report = render_report(&[
det(
Product::Sunshine,
vec![Evidence::Running {
process: "sunshine".into(),
}],
),
det(
Product::Apollo,
vec![Evidence::Installed {
at: "/usr/bin/apollo".into(),
}],
),
]);
assert!(report.contains("UNSUPPORTED"));
assert!(report.contains("Sunshine \u{2014} running now (sunshine)"));
assert!(report.contains("Apollo \u{2014} installed at /usr/bin/apollo"));
assert_eq!(
summary_labels(&[
det(
Product::Sunshine,
vec![Evidence::Running {
process: "sunshine".into()
}]
),
det(
Product::Apollo,
vec![Evidence::Installed { at: "x".into() }]
),
]),
vec!["Sunshine (running)".to_string(), "Apollo".to_string()]
);
}
#[test]
fn known_table_rows_are_well_formed() {
// Every known product carries at least a process name and a Windows service so the runtime
// scan and the installer's registry check stay in agreement.
for k in KNOWN {
assert!(
!k.processes.is_empty(),
"{:?} has no process name",
k.product
);
assert!(
!k.win_services.is_empty(),
"{:?} has no Windows service name",
k.product
);
}
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Linux conflicting-host facts: `/proc` for running processes, the standard systemd unit dirs +
//! flatpak app dirs + `PATH` for install markers. All best-effort and dependency-free (no
//! subprocess spawns) — a missing `/proc` or an unreadable dir simply yields no evidence.
use super::{Evidence, Known};
use std::path::Path;
/// Lowercased basenames of every process whose `/proc/<pid>/comm` we can read. `comm` is the
/// kernel's 15-char command name — every host we match on (sunshine, apollo, vibeshine, vibepollo,
/// luminalshine) fits within that, so no `/proc/<pid>/exe` readlink is needed.
pub fn running_processes() -> Vec<String> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return out;
};
for entry in entries.flatten() {
// Only numeric (pid) directories.
if !entry
.file_name()
.to_str()
.map(|n| n.bytes().all(|b| b.is_ascii_digit()))
.unwrap_or(false)
{
continue;
}
if let Ok(comm) = std::fs::read_to_string(entry.path().join("comm")) {
out.push(comm.trim().to_ascii_lowercase());
}
}
out
}
/// systemd unit registration + flatpak + `PATH` binary presence — the "installed" evidence.
pub fn static_evidence(known: &Known) -> Vec<Evidence> {
let mut ev = Vec::new();
// systemd units, system + per-user, in the dirs systemd actually reads.
let home = std::env::var_os("HOME");
let mut unit_dirs: Vec<String> = vec![
"/etc/systemd/system".into(),
"/run/systemd/system".into(),
"/usr/lib/systemd/system".into(),
"/lib/systemd/system".into(),
"/etc/systemd/user".into(),
"/usr/lib/systemd/user".into(),
];
if let Some(h) = &home {
unit_dirs.push(format!("{}/.config/systemd/user", h.to_string_lossy()));
}
for unit in known.linux_units {
let file = format!("{unit}.service");
if unit_dirs.iter().any(|d| Path::new(d).join(&file).exists()) {
ev.push(Evidence::Service { name: file });
}
}
// flatpak app installs (system + per-user).
let mut flatpak_roots: Vec<String> = vec!["/var/lib/flatpak/app".into()];
if let Some(h) = &home {
flatpak_roots.push(format!("{}/.local/share/flatpak/app", h.to_string_lossy()));
}
for id in known.flatpaks {
if flatpak_roots.iter().any(|r| Path::new(r).join(id).exists()) {
ev.push(Evidence::Installed {
at: format!("flatpak {id}"),
});
}
}
// A matching binary on PATH (covers manual / package installs the unit/flatpak checks miss).
let path = std::env::var_os("PATH");
for bin in known.processes {
if let Some(found) = find_on_path(bin, path.as_deref()) {
ev.push(Evidence::Installed { at: found });
}
}
ev
}
fn find_on_path(bin: &str, path: Option<&std::ffi::OsStr>) -> Option<String> {
let dirs = path.map(std::env::split_paths).into_iter().flatten();
// Always also probe the common bindirs, even if PATH is unset/narrow (e.g. a service context).
let extra = ["/usr/bin", "/usr/local/bin", "/bin", "/usr/games"]
.into_iter()
.map(std::path::PathBuf::from);
for dir in dirs.chain(extra) {
let cand = dir.join(bin);
if cand.is_file() {
return Some(cand.to_string_lossy().into_owned());
}
}
None
}
@@ -0,0 +1,87 @@
//! Windows conflicting-host facts: a Toolhelp process snapshot for what's running, the SCM for
//! registered services, and `%ProgramFiles%` for on-disk installs. All best-effort — any failing
//! query (no privilege, API error) yields no evidence rather than aborting startup.
use super::{Evidence, Known};
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
};
use windows_service::service::ServiceAccess;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
/// Lowercased executable basenames (without `.exe`) of every running process, via a Toolhelp
/// snapshot. `szExeFile` is the module base name (e.g. `sunshine.exe`), not a full path.
pub fn running_processes() -> Vec<String> {
let mut out = Vec::new();
// SAFETY: standard Toolhelp snapshot walk. The snapshot handle is closed on every exit path;
// `entry` is fully initialized (dwSize set) before Process32FirstW reads it.
unsafe {
let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else {
return out;
};
// Zeroed then dwSize set — the canonical Toolhelp init (no reliance on a Default impl for
// the 260-wide szExeFile array).
let mut entry = PROCESSENTRY32W {
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
..std::mem::zeroed()
};
if Process32FirstW(snap, &mut entry).is_ok() {
loop {
let end = entry
.szExeFile
.iter()
.position(|&c| c == 0)
.unwrap_or(entry.szExeFile.len());
let name = String::from_utf16_lossy(&entry.szExeFile[..end]).to_ascii_lowercase();
out.push(name.strip_suffix(".exe").unwrap_or(&name).to_string());
if Process32NextW(snap, &mut entry).is_err() {
break;
}
}
}
let _ = CloseHandle(snap);
}
out
}
/// SCM service registration + `%ProgramFiles%` install dirs — the "installed" evidence.
pub fn static_evidence(known: &Known) -> Vec<Evidence> {
let mut ev = Vec::new();
for svc in known.win_services {
if service_exists(svc) {
ev.push(Evidence::Service {
name: (*svc).to_string(),
});
}
}
for dir in known.win_dirs {
if let Some(at) = program_files_dir(dir) {
ev.push(Evidence::Installed { at });
}
}
ev
}
/// True if a service by this name is registered with the SCM (running or stopped). Opening it with
/// `QUERY_STATUS` fails cleanly when it doesn't exist.
fn service_exists(name: &str) -> bool {
let Ok(mgr) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
else {
return false;
};
mgr.open_service(name, ServiceAccess::QUERY_STATUS).is_ok()
}
/// The install directory under any of the Program Files roots, if it exists.
fn program_files_dir(dir: &str) -> Option<String> {
for var in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
if let Some(base) = std::env::var_os(var) {
let p = std::path::Path::new(&base).join(dir);
if p.is_dir() {
return Some(p.to_string_lossy().into_owned());
}
}
}
None
}
@@ -130,7 +130,7 @@ fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
.get_or_init(|| {
let table = load_api();
if let Err(e) = &table {
tracing::warn!("NVENC (Linux direct) API unavailable: {e}");
tracing::warn!(error = %e, "NVENC (Linux direct) API unavailable");
}
table
})
@@ -318,6 +318,11 @@ pub struct NvencCudaEncoder {
cursor: Option<cuda::CursorBlend>,
cursor_tried: bool,
cursor_serial: u64,
/// Suppress-until-success latches for the per-frame cursor upload/blend warns: a persistent
/// failure sits in the submit() hot path, so warn once per failure streak (reset on success)
/// rather than on every cursor-bearing frame, which would evict the log ring.
cursor_upload_warned: bool,
cursor_blend_warned: bool,
/// One-shot latch for [`diagnose_failed_open`](Self::diagnose_failed_open) so a rebuild-retry
/// burst (the session loop's bounded encoder resets) logs the diagnosis once, not per attempt.
diagnosed: bool,
@@ -386,6 +391,8 @@ impl NvencCudaEncoder {
cursor: None,
cursor_tried: false,
cursor_serial: u64::MAX,
cursor_upload_warned: false,
cursor_blend_warned: false,
diagnosed: false,
inited: false,
rfi_supported: false,
@@ -946,14 +953,12 @@ impl NvencCudaEncoder {
self.inited = true;
tracing::info!(
"NVENC CUDA session: {}x{}@{} {}-bit {} Mbps {:?} fmt={:?}",
self.width,
self.height,
self.fps,
self.bit_depth,
self.bitrate_bps / 1_000_000,
self.codec_guid,
self.buffer_fmt,
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
bit_depth = self.bit_depth,
mbps = self.bitrate_bps / 1_000_000,
codec = ?self.codec_guid,
fmt = ?self.buffer_fmt,
"NVENC CUDA session ready"
);
Ok(())
}
@@ -1058,9 +1063,19 @@ impl Encoder for NvencCudaEncoder {
if let Some(cb) = &self.cursor {
if self.cursor_serial != ov.serial {
match cb.upload(ov.rgba.as_slice(), ov.w, ov.h) {
Ok(()) => self.cursor_serial = ov.serial,
Ok(()) => {
self.cursor_serial = ov.serial;
self.cursor_upload_warned = false;
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "cursor upload failed")
if !self.cursor_upload_warned {
self.cursor_upload_warned = true;
tracing::warn!(
error = %format!("{e:#}"),
serial = ov.serial,
"NVENC (Linux): cursor upload failed — cursor not composited"
);
}
}
}
}
@@ -1079,7 +1094,15 @@ impl Encoder for NvencCudaEncoder {
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y),
};
if let Err(e) = r {
tracing::warn!(error = %format!("{e:#}"), "cursor blend launch failed");
if !self.cursor_blend_warned {
self.cursor_blend_warned = true;
tracing::warn!(
error = %format!("{e:#}"),
"NVENC (Linux): cursor blend launch failed — cursor not composited"
);
}
} else {
self.cursor_blend_warned = false;
}
}
}
@@ -1211,9 +1211,9 @@ impl Encoder for PyroWaveEncoder {
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
};
let mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
if pw::pyrowave_encoder_create(&einfo, &mut enc) != pw::pyrowave_result_PYROWAVE_SUCCESS
{
tracing::error!("pyrowave: encoder rebuild failed");
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
tracing::error!(result = ?r, "pyrowave: encoder rebuild failed");
return false;
}
self.pw_enc = enc;
@@ -1228,7 +1228,7 @@ impl Encoder for PyroWaveEncoder {
// (plan §4.6 — wavelet quality collapses well above the AIMD floor); until then this
// faithfully applies whatever the caller asks.
self.frame_budget = budget_for(bps, self.fps);
tracing::info!(
tracing::debug!(
mbps = bps / 1_000_000,
budget_kib = self.frame_budget / 1024,
"pyrowave: per-frame rate budget retargeted in place"
@@ -1304,7 +1304,7 @@ impl VulkanVideoEncoder {
// The retarget control command is recorded (execution follows submission order): the
// session's RC state IS the new rate from this frame on — later begins declare it.
self.bitrate = nb;
tracing::info!(
tracing::debug!(
mbps = nb / 1_000_000,
"vulkan-encode: rate control retargeted in place (no IDR)"
);
+26 -15
View File
@@ -596,7 +596,7 @@ fn try_factory() -> std::result::Result<&'static AmfLib, &'static str> {
let lib = load_factory();
if let Err(e) = &lib {
// Once per process; only reachable when the backend resolved to AMF on this box.
tracing::warn!("native AMF runtime unavailable: {e}");
tracing::warn!(error = %e, "native AMF runtime unavailable");
}
lib
})
@@ -1101,7 +1101,8 @@ unsafe fn set_prop(
} else {
tracing::debug!(
property = %name,
result = %format!("{} ({r})", result_name(r)),
result = result_name(r),
amf_code = r,
"optional AMF encoder property rejected (VCN generation/driver) — continuing"
);
Ok(false)
@@ -1666,15 +1667,18 @@ impl AmfEncoder {
tracing::info!(
codec = ?self.codec,
context = context_no,
device = format!("{:#x}", device.as_raw() as usize),
"native AMF encode active (context #{context_no}, {}x{}@{}, zero-copy D3D11 {} ring, runtime {}.{}.{})",
self.width,
self.height,
self.fps,
if self.ten_bit { "P010" } else { "NV12" },
(lib.version >> 48) & 0xffff,
(lib.version >> 32) & 0xffff,
(lib.version >> 16) & 0xffff,
device = %format_args!("{:#x}", device.as_raw() as usize),
width = self.width,
height = self.height,
fps = self.fps,
ring = if self.ten_bit { "P010" } else { "NV12" },
runtime = %format_args!(
"{}.{}.{}",
(lib.version >> 48) & 0xffff,
(lib.version >> 32) & 0xffff,
(lib.version >> 16) & 0xffff
),
"native AMF encode active (zero-copy D3D11)"
);
self.inner = Some(Inner {
comp,
@@ -2147,7 +2151,8 @@ impl Encoder for AmfEncoder {
);
if r != sys::AMF_OK {
tracing::warn!(
result = %format!("{} ({r})", result_name(r)),
result = result_name(r),
amf_code = r,
"AMF forced-keyframe picture type rejected"
);
}
@@ -2192,7 +2197,8 @@ impl Encoder for AmfEncoder {
if r != sys::AMF_OK {
tracing::warn!(
slot,
result = %format!("{} ({r})", result_name(r)),
result = result_name(r),
amf_code = r,
"AMF LTR mark rejected"
);
}
@@ -2212,7 +2218,8 @@ impl Encoder for AmfEncoder {
} else {
tracing::warn!(
slot,
result = %format!("{} ({r})", result_name(r)),
result = result_name(r),
amf_code = r,
"AMF LTR force-reference rejected — client stays frozen until its IDR fallback"
);
}
@@ -2558,7 +2565,11 @@ impl Encoder for AmfEncoder {
// end-of-stream (remaining AUs then surface through `poll` until AMF_EOF).
let r = unsafe { ((*(*inner.comp.0).vtbl).drain)(inner.comp.0) };
if r != sys::AMF_OK {
tracing::debug!(result = %format!("{} ({r})", result_name(r)), "AMF Drain");
tracing::debug!(
result = result_name(r),
amf_code = r,
"AMF Drain returned non-OK at flush"
);
}
Ok(())
}
@@ -277,7 +277,7 @@ unsafe fn open_win_encoder(
/// `false` keeps the negotiation honest: an AMF/QSV host resolves every session to 4:2:0 before the
/// Welcome. (Follow-up: implement + validate on an RDNA3+/Arc Windows box.)
pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
tracing::info!("AMF/QSV HEVC 4:4:4 encode is not implemented yet — declining (encoding 4:2:0)");
tracing::debug!("AMF/QSV HEVC 4:4:4 encode not implemented — declining (4:2:0)");
false
}
@@ -145,7 +145,7 @@ fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
if let Err(e) = &table {
// Once per process. Only reachable when something resolved to NVENC on this box
// (backend misdetect or a forced PUNKTFUNK_ENCODER=nvenc) — say why it will fail.
tracing::warn!("NVENC API unavailable: {e}");
tracing::warn!(error = %e, "NVENC API unavailable");
}
table
})
@@ -1049,11 +1049,11 @@ impl NvencD3d11Encoder {
}
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
};
tracing::info!(
tracing::debug!(
split_mode,
bit_depth = self.bit_depth,
pixel_rate,
"NVENC split-encode mode (0=auto 1=auto-forced 2=two 3=three 15=disable)"
"NVENC split-encode mode selected"
);
// Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
// `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a
@@ -1148,13 +1148,8 @@ impl NvencD3d11Encoder {
// mode (a split session occupies one hardware session per engine).
self.session_units = split_mode_units(split_mode);
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
if self.bitrate_bps < requested_bps {
tracing::info!(
requested_mbps = requested_bps / 1_000_000,
applied_mbps = self.bitrate_bps / 1_000_000,
"NVENC bitrate capped to this GPU's max for the codec"
);
}
// (The clamp path above already logs the requested→clamped bitrate at warn; no second
// info line for the same event here.)
// 5. one output bitstream per in-flight slot. There is NO encoder-owned input pool: the
// capturer's textures are registered on demand in `submit` and encoded in place.
@@ -262,7 +262,7 @@ fn run(
punktfunk_core::transport::grow_socket_buffers(&sock);
// The client pings the audio port (~every 500ms) so we learn where to send.
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
tracing::info!(port = AUDIO_PORT, "audio: awaiting client ping");
tracing::debug!(port = AUDIO_PORT, "audio: awaiting client ping");
let mut probe = [0u8; 256];
let (_, client) = sock
.recv_from(&mut probe)
@@ -275,7 +275,7 @@ fn run(
&sock,
punktfunk_core::transport::MediaClass::Audio,
);
tracing::info!(%client, "audio: client endpoint learned");
tracing::debug!(%client, "audio: client endpoint learned");
// Reuse the persistent capturer when its channel count still matches (drain stale
// buffered audio); otherwise drop it (clean PipeWire teardown) and open at the new count.
@@ -468,7 +468,7 @@ fn audio_body(
timestamp = timestamp.wrapping_add(frame_ms as u32);
sent += 1;
if sent % 400 == 0 {
tracing::info!(sent, "audio: streaming");
tracing::debug!(sent, "audio: streaming");
}
// Hold each frame to its packet-duration slot (skip if we've fallen behind a burst).
@@ -101,7 +101,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
},
Ok(None) => break,
Err(e) => {
tracing::warn!(error = %format!("{e:?}"), "control: service error");
tracing::warn!(error = ?e, "control: service error");
break;
}
}
@@ -132,7 +132,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
for wire in out {
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
{
tracing::warn!(error = %format!("{e:?}"), "rumble send failed");
tracing::warn!(error = ?e, "rumble send failed");
}
}
}
@@ -214,12 +214,12 @@ fn on_receive(
if inner == 0x0301 {
if let Some((first, last)) = decode_rfi_range(&pt) {
*state.rfi_range.lock().unwrap() = Some((first, last));
tracing::info!(first, last, "control: RFI request → invalidate ref frames");
tracing::debug!(first, last, "control: RFI request → invalidate ref frames");
} else {
state
.force_idr
.store(true, std::sync::atomic::Ordering::SeqCst);
tracing::info!("control: RFI request (no range) → keyframe");
tracing::debug!("control: RFI request (no range) → keyframe");
}
return;
}
@@ -227,8 +227,8 @@ fn on_receive(
state
.force_idr
.store(true, std::sync::atomic::Ordering::SeqCst);
tracing::info!(
ty = format!("{inner:#06x}"),
tracing::debug!(
ty = %format_args!("{inner:#06x}"),
"control: IDR request → keyframe"
);
return;
@@ -218,6 +218,17 @@ pub fn serve(
gamestream,
"punktfunk host"
);
// Surface a conflicting Moonlight-compatible host (Sunshine/Apollo/…) as early as possible:
// scan once (cached for `/local/summary` → tray + web console) and warn loudly if found.
let conflicts = crate::detect::init();
if !conflicts.is_empty() {
tracing::warn!(
target: "punktfunk::detect",
count = conflicts.len(),
"{}",
crate::detect::render_report(conflicts)
);
}
if gamestream {
tracing::warn!(
"GameStream/Moonlight compat ENABLED (--gamestream): its pairing runs over plain HTTP and \
@@ -265,14 +265,14 @@ impl Pairing {
store.push(s.client_cert_der.clone());
super::save_paired(&store);
}
tracing::info!(uniqueid, "pairing phase 4 — SUCCESS, client cert pinned");
tracing::info!(uniqueid, "pairing phase 4 complete — client cert pinned");
Ok(paired_xml("", true))
} else {
tracing::warn!(
uniqueid,
hash_ok,
sig_ok,
"pairing phase 4 — FAILED (PIN/cert)"
"pairing phase 4 rejected — PIN or cert mismatch"
);
map.remove(uniqueid);
Ok(paired_xml("", false))
+7 -5
View File
@@ -97,10 +97,12 @@ fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
// response until EOF, so we answer one message and close the connection (which signals
// the end of the response). Session state lives in `AppState`, not the connection.
if let Some(req) = read_message(&mut stream, &mut buf)? {
tracing::info!(
method = %req.method, cseq = %req.cseq,
"RTSP {} | {}", req.head.replace("\r\n", " | "),
if req.body.is_empty() { String::new() } else { format!("body: {}", req.body.replace("\r\n", " | ")) }
tracing::debug!(
method = %req.method,
cseq = %req.cseq,
headers = %req.head.replace("\r\n", " | "),
body = %req.body.replace("\r\n", " | "),
"RTSP request"
);
let resp = handle_request(&req, &state, peer);
stream.write_all(resp.as_bytes()).context("RTSP write")?;
@@ -404,7 +406,7 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
};
let matches_ours = (hdr && csc >> 1 == 2 || !hdr && csc >> 1 == 1) && csc & 1 == 0;
if matches_ours {
tracing::info!(
tracing::debug!(
csc,
space,
range,
@@ -879,7 +879,7 @@ fn stream_body(
"video: streaming (perf)"
);
} else {
tracing::info!(
tracing::debug!(
fps = fps_count,
sent_batches,
dropped_batches,
+4 -1
View File
@@ -49,7 +49,10 @@ pub(crate) async fn serve_https(
let (tcp, peer) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "HTTPS accept failed");
// A persistent accept() error (fd exhaustion / EMFILE) would otherwise hot-spin
// this loop and storm the log; back off so a stuck accept can't burn a core.
tracing::warn!(error = %e, "HTTPS accept failed — backing off 100ms");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
continue;
}
};
+1 -1
View File
@@ -389,7 +389,7 @@ impl GpuPrefStore {
Ok(bytes) => match serde_json::from_slice::<GpuPreference>(&bytes) {
Ok(p) => p,
Err(e) => {
tracing::warn!(path = %path.display(), "gpu-settings.json unreadable — using Auto: {e}");
tracing::warn!(path = %path.display(), error = %e, "gpu-settings.json unreadable — using default (Auto)");
GpuPreference::default()
}
},
+1 -1
View File
@@ -250,7 +250,7 @@ fn injector_service_thread(rx: std::sync::mpsc::Receiver<InputEvent>) {
last_failed = None;
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "pointer/keyboard injection unavailable — will retry");
tracing::warn!(error = %format!("{e:#}"), "pointer/keyboard injection unavailable — will retry");
last_failed = Some(std::time::Instant::now());
}
}
@@ -569,7 +569,7 @@ impl EiState {
?cap,
devices = self.devices.len(),
resumed = self.devices.iter().filter(|d| d.resumed).count(),
"libei: DROP — no resumed device exposes this capability"
"libei: dropped event — no resumed device exposes this capability"
);
}
// No resumed device with this capability yet. For touch this is usually permanent on
@@ -727,10 +727,14 @@ impl EiState {
dev.frame(self.last_serial, self.now_us());
}
if let Err(e) = ctx.flush() {
tracing::warn!(error = %e, "libei: ctx.flush failed");
// In the per-input-event hot path: a dead EIS socket fails flush on every event
// (mouse-move = 100s/s), so gate the warn behind the same `loud` sampler as its siblings.
if loud {
tracing::warn!(error = %e, "libei: ctx.flush failed");
}
}
if loud {
tracing::info!(n, kind = ?ev.kind, idx, emitted, "libei: emitted");
tracing::debug!(n, kind = ?ev.kind, idx, emitted, "libei: emitted");
}
}
}
@@ -178,8 +178,8 @@ impl TritonPad {
let reply = triton_feature_reply(&self.last_set, &self.serial, self.unit_id);
if reply[1] != self.last_get_logged {
self.last_get_logged = reply[1];
tracing::info!(
cmd = format!("{:#04x}", reply[1]),
tracing::debug!(
cmd = %format_args!("{:#04x}", reply[1]),
"virtual SC2: answering feature GET"
);
}
@@ -259,8 +259,8 @@ impl UsbInterfaceHandler for TritonHandler {
};
if reply[1] != self.last_get_logged {
self.last_get_logged = reply[1];
tracing::info!(
cmd = format!("{:#04x}", reply[1]),
tracing::debug!(
cmd = %format_args!("{:#04x}", reply[1]),
"virtual SC2 usbip: answering feature GET"
);
}
+17
View File
@@ -21,6 +21,7 @@
mod audio;
mod capture;
mod config;
mod detect;
mod discovery;
mod wol;
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
@@ -31,6 +32,9 @@ mod crash;
#[cfg(target_os = "windows")]
#[path = "windows/ddc.rs"]
mod ddc;
#[cfg(target_os = "windows")]
#[path = "windows/display_events.rs"]
mod display_events;
#[cfg(target_os = "linux")]
#[path = "linux/dmabuf_fence.rs"]
mod dmabuf_fence;
@@ -211,6 +215,19 @@ fn real_main() -> Result<()> {
monitor_devnode::startup_recover();
gamestream::serve(mgmt_opts, native, gamestream)
}
// Report other Moonlight-compatible hosts (Sunshine/Apollo/…) installed or running on this
// machine — side-by-side use is unsupported. Exit 1 if any are found (so the installers and
// support scripts can gate on it), 0 if clean. The host also runs this at `serve` startup.
Some("detect-conflicts") => {
let found = detect::scan();
if found.is_empty() {
println!("No conflicting game-streaming host detected.");
Ok(())
} else {
print!("{}", detect::render_report(&found));
std::process::exit(1);
}
}
// Print the management API's OpenAPI document (for client codegen).
Some("openapi") => {
print!("{}", mgmt::openapi_json());
+8
View File
@@ -419,6 +419,11 @@ struct LocalSummary {
/// (`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is
/// held; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.
kept_displays: u32,
/// Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —
/// running one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);
/// the tray/console surface them so the clash is visible before pairing silently fails.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
conflicts: Vec<String>,
}
/// A paired (certificate-pinned) Moonlight client.
@@ -1557,6 +1562,9 @@ async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummar
.iter()
.filter(|d| d.state == "lingering" || d.state == "pinned")
.count() as u32,
// Cached at `serve` startup (empty when nothing was detected / never scanned) — no per-poll
// process enumeration.
conflicts: crate::detect::summary_labels(crate::detect::snapshot()),
})
}
+30 -14
View File
@@ -319,8 +319,12 @@ pub(crate) async fn serve(
tracing::info!(
paired = st.paired_clients,
require = opts.require_pairing,
"PAIRING ARMED — enter this PIN on the client to pair: {pin}"
"pairing armed — enter the PIN shown on the console to pair a client"
);
// The PIN is a shared secret: print it straight to the operator's terminal, NOT through
// tracing. A tracing event also lands in the DEBUG log ring that field bug reports ship
// (GET /api/v1/logs), which must never carry the pairing secret.
eprintln!("[punktfunk] pairing PIN: {pin} (enter this on the client to pair)");
}
let last_pairing = Arc::new(std::sync::Mutex::new(None::<std::time::Instant>));
let opts = Arc::new(opts);
@@ -613,7 +617,7 @@ async fn pair_ceremony(
}
tracing::info!(name = %req.name, "pairing complete — client trusted");
} else {
tracing::warn!(name = %req.name, "pairing FAILED (wrong PIN) — fingerprint not stored");
tracing::warn!(name = %req.name, "pairing rejected (wrong PIN) — fingerprint not stored");
}
io::write_msg(&mut send, &PairResult { ok }.encode()).await?;
let _ = send.finish();
@@ -1308,7 +1312,7 @@ async fn serve_session(
let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1));
fec_target_ctl.store(target, Ordering::Relaxed);
if prev != target {
tracing::info!(
tracing::debug!(
loss_ppm = rep.loss_ppm,
fec_pct = target,
prev_fec_pct = prev,
@@ -1336,7 +1340,7 @@ async fn serve_session(
} else {
resolve_bitrate_kbps(req.bitrate_kbps)
};
tracing::info!(
tracing::debug!(
requested_kbps = req.bitrate_kbps,
resolved_kbps = resolved,
"mid-stream bitrate change requested"
@@ -1521,7 +1525,7 @@ async fn serve_session(
std::thread::Builder::new()
.name("punktfunk1-audio".into())
.spawn(move || audio_thread(conn, stop, cap, channels))
.map_err(|e| tracing::error!(error = %e, "audio thread spawn failed — session continues without audio"))
.map_err(|e| tracing::warn!(error = %e, "audio thread spawn failed — session continues without audio"))
.ok()
} else {
None
@@ -2589,7 +2593,7 @@ fn input_thread(
// Log the silent→active transition (once per buzz) so a live test can tell
// "host never gets rumble from the game" apart from "client doesn't render it".
if prev == (0, 0) && (low != 0 || high != 0) {
tracing::info!(pad, low, high, "rumble: forwarding to client (0xCA)");
tracing::debug!(pad, low, high, "rumble: forwarding to client (0xCA)");
}
rumble_state[idx] = (low, high);
rumble_seen[idx] = true;
@@ -2785,7 +2789,7 @@ fn audio_thread(
let mut enc = match NativeAudioEnc::new(want) {
Ok(e) => e,
Err(e) => {
tracing::error!(error = %e, "opus encoder");
tracing::warn!(error = %e, "opus encoder init failed — session continues without audio");
*audio_cap.lock().unwrap() = Some(capturer);
return;
}
@@ -2804,6 +2808,9 @@ fn audio_thread(
// restart). The first open already happened above; failing THAT still ends the session quietly.
let mut capturer = Some(capturer);
let mut last_failed: Option<std::time::Instant> = None;
// A stuck Opus encoder would fail on every 5 ms frame (~200/s); power-of-two throttle the
// warn so it can't flood stderr + the log ring while still surfacing that it's failing.
let mut opus_encode_errs: u64 = 0;
tracing::info!(
channels = want,
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
@@ -2851,7 +2858,16 @@ fn audio_thread(
}
seq = seq.wrapping_add(1);
}
Err(e) => tracing::warn!(error = %e, "opus encode"),
Err(e) => {
opus_encode_errs += 1;
if opus_encode_errs.is_power_of_two() {
tracing::warn!(
error = %e,
count = opus_encode_errs,
"opus encode failed — dropping audio frame"
);
}
}
}
}
}
@@ -3524,7 +3540,7 @@ pub(crate) fn boost_thread_priority(critical: bool) {
match SetThreadPriority(GetCurrentThread(), prio) {
Ok(()) => tracing::debug!(critical, "thread priority raised"),
Err(e) => {
tracing::debug!(critical, error = %format!("{e:?}"), "SetThreadPriority failed")
tracing::debug!(critical, error = ?e, "SetThreadPriority failed")
}
}
}
@@ -4424,7 +4440,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
} else {
"transient"
};
tracing::error!(error = %chain, kind,
tracing::warn!(error = %chain, kind,
"session-switch rebuild failed — staying on the current backend");
}
}
@@ -4500,7 +4516,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
last_forced_idr = Some(std::time::Instant::now()); // fresh encoder opens on an IDR — anchor the cooldown
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), ?new_mode,
tracing::warn!(error = %format!("{e:#}"), ?new_mode,
"mode-switch rebuild failed — staying on the current mode");
// H2 rollback: the control task acked the switch BEFORE this rebuild, so the
// client's mode slot already flipped to `new_mode`. A second accepted ack
@@ -4577,7 +4593,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
last_forced_idr = Some(std::time::Instant::now());
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), to_kbps = new_kbps,
tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps,
"bitrate-change encoder rebuild failed — keeping the current rate");
}
}
@@ -4931,7 +4947,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
session (see the error above for the cause)");
return Err(e).context("encoder submit");
}
tracing::error!(error = %format!("{e:#}"), reset = encoder_resets,
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
max = MAX_ENCODER_RESETS,
"encoder submit failed — encoder rebuilt in place, forcing an IDR");
last_au_at = std::time::Instant::now();
@@ -5090,7 +5106,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
return Err(poll_err.unwrap_or_else(|| anyhow!("{why}")))
.context("encoder stalled — in-place rebuild unavailable or exhausted");
}
tracing::error!(reset = encoder_resets, max = MAX_ENCODER_RESETS, %why,
tracing::warn!(reset = encoder_resets, max = MAX_ENCODER_RESETS, %why,
"encode stall detected — encoder rebuilt in place, forcing an IDR");
last_au_at = std::time::Instant::now();
}
+1 -1
View File
@@ -983,7 +983,7 @@ pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
if gamescope::is_available() {
true
} else {
tracing::info!(
tracing::warn!(
"game_session=dedicated but gamescope is unavailable — falling back to auto routing"
);
false
@@ -1278,7 +1278,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
// and the transient unit has no Restart= — without supervision the rest of this poll would
// wait on a corpse. Re-run the unit so every readiness attempt inside the deadline is used.
if !unit_starting_or_active(unit_name) {
tracing::info!(
tracing::warn!(
unit = unit_name,
"gamescope session: transient unit died (missed the wrapper's 5 s gamescope \
readiness window?) relaunching"
@@ -685,7 +685,7 @@ fn run(
.dispatch_pending(&mut state)
.context("dispatch_pending")?;
if state.closed {
tracing::warn!("KWin closed the virtual-output stream");
tracing::warn!(output = %name, node_id, "KWin closed the virtual-output stream");
break;
}
conn.flush().context("wayland flush")?;
@@ -255,12 +255,12 @@ fn session_thread(
Ok(dc) => match get_state(&dc).await {
Ok(state) => Some((dc, state)),
Err(e) => {
tracing::warn!("mutter: GetCurrentState (pre) failed ({e:#}); topology + scale persistence off");
tracing::warn!(error = %format!("{e:#}"), "mutter: GetCurrentState (pre) failed; topology + scale persistence off");
None
}
},
Err(e) => {
tracing::warn!("mutter: DisplayConfig unavailable ({e:#}); topology + scale persistence off");
tracing::warn!(error = %format!("{e:#}"), "mutter: DisplayConfig unavailable; topology + scale persistence off");
None
}
};
@@ -301,14 +301,16 @@ fn session_thread(
if exclusive { "disabled" } else { "kept" }
),
Err(e) => tracing::warn!(
"mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor"
error = %format!("{e:#}"),
"mutter: could not set the virtual output primary; streaming continues — the desktop may render on the physical monitor"
),
}
}
tracked = Some((dc, pre, vconn));
}
Err(e) => tracing::warn!(
"mutter: virtual connector not identified ({e:#}); topology + scale persistence off"
error = %format!("{e:#}"),
"mutter: virtual connector not identified; topology + scale persistence off"
),
}
}
@@ -737,7 +739,8 @@ async fn make_virtual_primary(
tracing::warn!(
scale,
derived,
"mutter: ApplyMonitorsConfig at the remembered scale failed ({e:#}); retrying at the derived scale"
error = %format!("{e:#}"),
"mutter: ApplyMonitorsConfig at the remembered scale failed — retrying at the derived scale"
);
scale = derived;
}
+6 -3
View File
@@ -234,9 +234,12 @@ pub struct DisplayPolicy {
/// untouched.
#[serde(default)]
pub ddc_power_off: bool,
/// EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,
/// additionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose
/// hot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same
/// EXPERIMENTAL (Windows): DISABLE physical monitors' PnP device nodes for the stream's
/// duration (persistently, so a standby monitor/TV whose hot-plug events re-arrive stays
/// disabled) and re-enable them at teardown. Two selectors: the monitors an `Exclusive`
/// isolate deactivated, plus — in ANY topology — external monitors that are connected but not
/// part of the desktop (the standby TV that was never active, whose input auto-scan /
/// instant-on HPD cycling re-probes the link every few seconds). Targets the same
/// "connected-but-dark head" periodic-stutter class as [`Self::ddc_power_off`], but at the
/// Windows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD
/// re-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host
@@ -906,8 +906,9 @@ impl VirtualDisplayManager {
Some(n) => {
tracing::info!(
backend = self.driver.name(),
"target {} -> {n}",
added.target_id
target_id = added.target_id,
gdi = %n,
"IDD target activated into a display path"
);
// ADD only advertises the mode; force it active so DXGI captures the requested size.
set_active_mode(n, mode);
@@ -1015,6 +1016,26 @@ impl VirtualDisplayManager {
}
}
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
// EXPERIMENTAL `pnp_disable_monitors`, second selector (ANY topology): monitors
// that are connected but NOT part of the desktop — the standby TV/monitor the
// deactivated-set selector above structurally misses (it never had an active path
// to deactivate), yet whose periodic standby wake events drive the same Windows
// reaction cascade (rationale in `windows/monitor_devnode.rs`). Runs AFTER the
// settle sleep so the active flags it reads are the committed ones (a display
// still mid-activation from the primary topology's force-EXTEND must not read as
// inactive and get disabled); in Extend the active physical panels are untouched
// by construction. First member only — the sweep is group-scoped like the
// isolate; later members join an already-swept desktop.
if first_member && crate::vdisplay::policy::prefs().pnp_disable_monitors() {
let mut keep = inner.target_ids();
keep.push(added.target_id);
for id in crate::monitor_devnode::disable_connected_inactive(&keep) {
if !inner.group.pnp_disabled.contains(&id) {
inner.group.pnp_disabled.push(id);
}
}
}
}
None => tracing::warn!(
"virtual-display target {} not yet an active display path (auto-activate, EXTEND \
@@ -1067,8 +1088,11 @@ impl VirtualDisplayManager {
) -> Result<Monitor> {
tracing::info!(
slot,
old = %format!("{}x{}@{}", old.mode.width, old.mode.height, old.mode.refresh_hz),
new = %format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
old = format!(
"{}x{}@{}",
old.mode.width, old.mode.height, old.mode.refresh_hz
),
new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
old_target = old.target_id,
"virtual-display: re-arriving monitor for a mid-stream resize (exact mode)"
);
@@ -1207,17 +1231,19 @@ impl VirtualDisplayManager {
// (stopped FIRST, as the per-monitor pinger was), and the group's topology restore runs
// — first-in captured it, last-out restores it (design §6.1).
self.stop_pinger();
// EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let
// them re-arrive, so a CCD restore below re-activates paths whose monitors exist
// again (a disabled devnode would leave the restored path modeless/EDID-less).
// OUTSIDE the ccd_saved gate: the connected-inactive sweep disables devnodes in
// Extend/Primary sessions too, where no isolate snapshot exists to restore.
let pnp_disabled = std::mem::take(&mut inner.group.pnp_disabled);
if !pnp_disabled.is_empty() {
crate::monitor_devnode::enable_instances(&pnp_disabled);
thread::sleep(Duration::from_millis(300));
}
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero
// displays.
if let Some(saved) = inner.group.ccd_saved.take() {
// EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let
// them re-arrive, so the CCD restore below re-activates paths whose monitors exist
// again (a disabled devnode would leave the restored path modeless/EDID-less).
let pnp_disabled = std::mem::take(&mut inner.group.pnp_disabled);
if !pnp_disabled.is_empty() {
crate::monitor_devnode::enable_instances(&pnp_disabled);
thread::sleep(Duration::from_millis(300));
}
restore_displays_ccd(&saved);
// EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and
// returning signal alone wakes DPMS-off panels on most firmware — the explicit ON is
@@ -1255,7 +1281,10 @@ impl VirtualDisplayManager {
if is_device_gone(&e) {
self.invalidate_device(&e);
}
tracing::warn!("virtual-display REMOVE failed: {e:#}");
tracing::warn!(
target_id = mon.target_id,
"virtual-display REMOVE failed: {e:#}"
);
} else {
tracing::info!(
backend = self.driver.name(),
@@ -164,10 +164,14 @@ fn restart_vdisplay_device() -> bool {
{
Ok(o) => {
let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
tracing::warn!(
%status,
"pf-vdisplay: cycled the adapter device (hostless-zombie recovery)"
);
if status == "ABSENT" {
tracing::warn!("pf-vdisplay: no adapter devnode to cycle — driver not installed");
} else {
tracing::warn!(
%status,
"pf-vdisplay: cycled the adapter device (hostless-zombie recovery)"
);
}
status != "ABSENT"
}
Err(e) => {
@@ -531,13 +535,13 @@ impl VdisplayDriver for PfVdisplayDriver {
HighPart: reply.adapter_luid_high,
};
tracing::info!(
"pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x}, wudf_pid={})",
target_id = reply.target_id,
adapter_luid = %format_args!("{:#x}", luid.LowPart),
wudf_pid = reply.wudf_pid,
"pf-vdisplay monitor created {}x{}@{}",
mode.width,
mode.height,
mode.refresh_hz,
reply.target_id,
luid.LowPart,
reply.wudf_pid
mode.refresh_hz
);
// Per-client identity diagnostic: did the driver honor the host's preferred (stable) monitor id?
// A pre-Phase-2 driver leaves resolved_monitor_id=0 (it ignored the field); a current driver echoes
+3 -3
View File
@@ -181,9 +181,9 @@ pub fn panel_off_except(exclude_gdi: &str) -> u32 {
acked += set_power(m.hmon, &m.device, POWER_OFF);
}
if acked == 0 {
tracing::info!(
"DDC/CI: no panel accepted the off command — the experiment is a no-op on this box \
(monitors without DDC/CI, or none besides the virtual display)"
tracing::debug!(
"DDC/CI: no physical panel accepted the DPMS-off command \
(no DDC/CI-capable panel besides the virtual display)"
);
}
acked
@@ -0,0 +1,337 @@
//! OS display-event listener — the attribution sensor for the periodic-stutter disturbance class.
//!
//! The capture-stall watch ([`crate::capture::windows::idd_push`]) can SAY "DWM stopped composing
//! on a stable period", but not WHY. Field evidence (Apollo's Stuttering Clinic, Apollo #384,
//! Tom's HW "stutter from disabled-but-connected monitors") points at a connected-but-idle sink
//! (standby TV/monitor, active HDMI cable, KVM/AVR) re-probing the link every few seconds; the GPU
//! driver services each probe below the topology layer, and on some boxes Windows additionally
//! tears down + re-arrives the monitor's devnode each time. This module timestamps everything
//! Windows lets user mode see of that reaction so the stall log can name the disturbance instead
//! of guessing:
//!
//! - `WM_DEVICECHANGE` + `RegisterDeviceNotificationW(GUID_DEVINTERFACE_MONITOR)`: monitor device
//! interface arrival/removal — fires on devnode churn even when the final topology is unchanged
//! (the "reaction cascade" class `pnp_disable_monitors` suppresses), with the interface path
//! naming WHICH monitor pulsed.
//! - `DBT_DEVNODES_CHANGED`: the broadcast catch-all for PnP tree churn (no payload).
//! - `WM_DISPLAYCHANGE`: an actual mode/topology commit reached the desktop (this one does NOT
//! fire for a pure probe with no mode delta — its absence is itself a signal).
//!
//! A pure driver-internal probe (EDID/DDC read, DP link retrain) emits NONE of these — that
//! absence, paired with metronomic stalls, is what discriminates "driver services a standby sink
//! below the OS" from "Windows re-enumerates the monitor". Kernel-precise attribution (DxgKrnl ETW
//! event 272 `DxgkCbIndicateChildStatus`) is a possible follow-up; this listener is the cheap
//! always-on first stage.
//!
//! The listener thread also keeps a cached CCD target inventory (refreshed on each event + a slow
//! timer), so the capture thread can name connected-but-inactive external displays — the prime
//! suspects — without ever touching the CCD lock itself (the display-config lock is exactly what
//! stalls during churn; the capture thread must never block on it).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::VecDeque;
use std::sync::{Mutex, Once, OnceLock};
use std::time::Instant;
use windows::core::PCWSTR;
use windows::Win32::Devices::Display::GUID_DEVINTERFACE_MONITOR;
use windows::Win32::Foundation::{HANDLE, HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, RegisterClassW,
RegisterDeviceNotificationW, SetTimer, DBT_DEVICEARRIVAL, DBT_DEVICEREMOVECOMPLETE,
DBT_DEVNODES_CHANGED, DBT_DEVTYP_DEVICEINTERFACE, DEVICE_NOTIFY_WINDOW_HANDLE,
DEV_BROADCAST_DEVICEINTERFACE_W, DEV_BROADCAST_HDR, MSG, WINDOW_EX_STYLE, WM_DEVICECHANGE,
WM_DISPLAYCHANGE, WM_TIMER, WNDCLASSW, WS_OVERLAPPED,
};
/// One OS-visible display event, timestamped at receipt.
#[derive(Clone)]
pub(crate) struct DisplayEvent {
pub at: Instant,
pub kind: DisplayEventKind,
/// Monitor device instance id for arrival/removal (e.g. `DISPLAY\GSM83CD\...`), else `None`.
pub detail: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum DisplayEventKind {
/// A monitor device interface ARRIVED — a sink (re)connected as Windows sees it.
MonitorArrival,
/// A monitor device interface was REMOVED — a sink dropped as Windows sees it.
MonitorRemoval,
/// PnP device-tree churn (broadcast, no payload) — re-enumeration passed through.
DevNodesChanged,
/// A mode/topology commit reached the desktop (resolution/layout actually changed).
DisplayChange,
}
impl DisplayEventKind {
fn label(self) -> &'static str {
match self {
Self::MonitorArrival => "monitor-arrival",
Self::MonitorRemoval => "monitor-removal",
Self::DevNodesChanged => "devnodes-changed",
Self::DisplayChange => "display-change",
}
}
}
struct State {
/// Recent events, oldest-first, capped at [`RING_CAP`].
events: VecDeque<DisplayEvent>,
/// Cached CCD target inventory (see module docs for why the cache exists).
inventory: Vec<crate::win_display::TargetInventory>,
}
/// Ring depth: at the observed worst case (a probe cycle every ~2 s, ≤4 events per cycle) this
/// holds well over a minute of history — the stall correlator only ever asks about the last gap.
const RING_CAP: usize = 128;
fn state() -> &'static Mutex<State> {
static STATE: OnceLock<Mutex<State>> = OnceLock::new();
STATE.get_or_init(|| {
Mutex::new(State {
events: VecDeque::with_capacity(RING_CAP),
inventory: Vec::new(),
})
})
}
/// Start the listener thread (idempotent). Degraded-not-fatal: if window/registration creation
/// fails the ring just stays empty — the stall log then reports "listener unavailable" naturally
/// via empty summaries, and streaming is unaffected.
pub(crate) fn spawn_once() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let spawned = std::thread::Builder::new()
.name("pf-display-events".into())
.spawn(pump);
if let Err(e) = spawned {
tracing::warn!(
error = %e,
"display-event listener thread failed to spawn — stall logs won't carry OS event attribution"
);
}
});
}
/// Events with `from <= at <= to`, oldest-first.
pub(crate) fn events_between(from: Instant, to: Instant) -> Vec<DisplayEvent> {
let st = state().lock().unwrap();
st.events
.iter()
.filter(|e| e.at >= from && e.at <= to)
.cloned()
.collect()
}
/// Compact one-line summary for log fields: `"monitor-removal x2 (DISPLAY\GSM83CD\...),
/// devnodes-changed x1"`; `"none"` when empty.
pub(crate) fn summarize(events: &[DisplayEvent]) -> String {
if events.is_empty() {
return "none".into();
}
let mut out: Vec<String> = Vec::new();
for kind in [
DisplayEventKind::MonitorArrival,
DisplayEventKind::MonitorRemoval,
DisplayEventKind::DevNodesChanged,
DisplayEventKind::DisplayChange,
] {
let hits: Vec<&DisplayEvent> = events.iter().filter(|e| e.kind == kind).collect();
if hits.is_empty() {
continue;
}
let detail = hits
.iter()
.rev()
.find_map(|e| e.detail.as_deref())
.map(|d| format!(" ({d})"))
.unwrap_or_default();
out.push(format!("{} x{}{}", kind.label(), hits.len(), detail));
}
out.join(", ")
}
/// The prime suspects for link-probe disturbances, from the cached inventory: external physical
/// displays that are CONNECTED but not part of the desktop (standby TV / input-switched monitor).
/// Rendered as `"<friendly> (<connector>)"`. Never blocks on the CCD lock.
pub(crate) fn connected_inactive_externals() -> Vec<String> {
let st = state().lock().unwrap();
st.inventory
.iter()
.filter(|t| t.external_physical && !t.active)
.map(|t| format!("{} ({})", t.friendly, t.tech))
.collect()
}
fn push_event(kind: DisplayEventKind, detail: Option<String>) {
let mut st = state().lock().unwrap();
if st.events.len() >= RING_CAP {
st.events.pop_front();
}
st.events.push_back(DisplayEvent {
at: Instant::now(),
kind,
detail,
});
}
fn refresh_inventory() {
// SAFETY: `target_inventory` only runs read-only CCD queries over locals (see its SAFETY doc);
// called from the listener thread, never the capture thread.
let inv = unsafe { crate::win_display::target_inventory() };
if !inv.is_empty() {
state().lock().unwrap().inventory = inv;
}
}
/// Inventory refresh timer: 15 s keeps the "connected-but-inactive" suspect list fresh enough for
/// a warn that rate-limits to 30 s, without measurable CCD traffic.
const INVENTORY_TIMER_MS: u32 = 15_000;
/// `DBT_DEVNODES_CHANGED` arrives as `wParam` on `WM_DEVICECHANGE` without a registration; the
/// interface arrivals/removals need the `RegisterDeviceNotificationW` below.
unsafe extern "system" fn wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match msg {
WM_DISPLAYCHANGE => {
// lParam packs the new primary resolution — worth carrying: it distinguishes a real
// mode change from a same-mode re-commit when reading a field log.
let (w, h) = (
(lparam.0 & 0xffff) as u32,
((lparam.0 >> 16) & 0xffff) as u32,
);
push_event(DisplayEventKind::DisplayChange, Some(format!("{w}x{h}")));
refresh_inventory();
LRESULT(0)
}
WM_DEVICECHANGE => {
let event = wparam.0 as u32;
if event == DBT_DEVNODES_CHANGED {
push_event(DisplayEventKind::DevNodesChanged, None);
refresh_inventory();
} else if event == DBT_DEVICEARRIVAL || event == DBT_DEVICEREMOVECOMPLETE {
let kind = if event == DBT_DEVICEARRIVAL {
DisplayEventKind::MonitorArrival
} else {
DisplayEventKind::MonitorRemoval
};
// SAFETY: for these two events lParam is documented to point at a
// DEV_BROADCAST_HDR (or be 0 — checked). We only read the header fields, and only
// reinterpret as DEV_BROADCAST_DEVICEINTERFACE_W after checking dbch_devicetype,
// reading at most dbch_size bytes — the size the sender declared.
let detail = unsafe {
let hdr = lparam.0 as *const DEV_BROADCAST_HDR;
if hdr.is_null() || (*hdr).dbch_devicetype != DBT_DEVTYP_DEVICEINTERFACE {
None
} else {
let di = hdr as *const DEV_BROADCAST_DEVICEINTERFACE_W;
let head = std::mem::offset_of!(DEV_BROADCAST_DEVICEINTERFACE_W, dbcc_name);
let bytes = ((*hdr).dbch_size as usize).saturating_sub(head);
let name = std::slice::from_raw_parts(
std::ptr::addr_of!((*di).dbcc_name).cast::<u16>(),
(bytes / 2).min(512),
);
let end = name.iter().position(|&c| c == 0).unwrap_or(name.len());
crate::monitor_devnode::instance_id_from_interface_path(
&String::from_utf16_lossy(&name[..end]),
)
}
};
push_event(kind, detail);
refresh_inventory();
}
LRESULT(0)
}
WM_TIMER => {
refresh_inventory();
LRESULT(0)
}
// SAFETY: default handling for everything else — the standard wndproc tail call.
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
}
}
/// The listener thread: a hidden TOP-LEVEL window (message-ONLY windows receive neither
/// `WM_DISPLAYCHANGE` nor broadcast `WM_DEVICECHANGE` — the classic pitfall) + a device-interface
/// registration for monitors + a blocking message pump. Runs for the process lifetime.
fn pump() {
refresh_inventory(); // baseline before any event arrives
let class: Vec<u16> = "pf-display-events\0".encode_utf16().collect();
// SAFETY: straight-line Win32 window bring-up on this thread. `class` outlives every use of
// its pointer (it lives to the end of the fn, the pump loops forever). All handles passed on
// are the ones the preceding calls returned; failure of any step returns out of the thread
// (degraded mode, see `spawn_once`). The DEV_BROADCAST_DEVICEINTERFACE_W filter is a fully
// initialised local read synchronously by RegisterDeviceNotificationW.
unsafe {
let Ok(hinstance) = GetModuleHandleW(None) else {
tracing::warn!(
"display-event listener: GetModuleHandleW failed — no OS event attribution"
);
return;
};
let wc = WNDCLASSW {
lpfnWndProc: Some(wnd_proc),
hInstance: hinstance.into(),
lpszClassName: PCWSTR(class.as_ptr()),
..Default::default()
};
if RegisterClassW(&wc) == 0 {
tracing::warn!(
"display-event listener: RegisterClassW failed — no OS event attribution"
);
return;
}
let hwnd = match CreateWindowExW(
WINDOW_EX_STYLE(0),
PCWSTR(class.as_ptr()),
PCWSTR(class.as_ptr()),
WS_OVERLAPPED, // hidden: never shown, never gets focus — exists only to receive broadcasts
0,
0,
0,
0,
None,
None,
Some(wc.hInstance),
None,
) {
Ok(w) => w,
Err(e) => {
tracing::warn!(error = %e, "display-event listener: CreateWindowExW failed — no OS event attribution");
return;
}
};
let filter = DEV_BROADCAST_DEVICEINTERFACE_W {
dbcc_size: std::mem::size_of::<DEV_BROADCAST_DEVICEINTERFACE_W>() as u32,
dbcc_devicetype: DBT_DEVTYP_DEVICEINTERFACE.0,
dbcc_classguid: GUID_DEVINTERFACE_MONITOR,
..Default::default()
};
if let Err(e) = RegisterDeviceNotificationW(
HANDLE(hwnd.0),
std::ptr::from_ref(&filter).cast(),
DEVICE_NOTIFY_WINDOW_HANDLE,
) {
// DBT_DEVNODES_CHANGED and WM_DISPLAYCHANGE still arrive — partial attribution.
tracing::warn!(error = %e, "display-event listener: monitor-interface registration failed — arrival/removal detail unavailable");
}
SetTimer(Some(hwnd), 1, INVENTORY_TIMER_MS, None);
tracing::debug!(
"display-event listener running (monitor hot-plug + display-change attribution)"
);
let mut msg = MSG::default();
while GetMessageW(&mut msg, None, 0, 0).as_bool() {
DispatchMessageW(&msg);
}
}
}
@@ -7,13 +7,16 @@
//! (Apollo #368's Device-Manager refresh at every hitch; our own reporter's TV where unplugging the
//! cable removes a metronomic ~4 s double-jolt) says this reaction cascade is the expensive part.
//!
//! This module disables the deactivated monitors' devnodes for the stream's duration
//! This module disables physical monitors' devnodes for the stream's duration
//! (`CM_Disable_DevNode` with `CM_DISABLE_PERSIST`, so a devnode that hot-plug re-arrives STAYS
//! disabled — that persistence is the whole point) and re-enables them at teardown before the CCD
//! restore. Selection is precise: only the monitors on targets the isolate actually deactivated,
//! mapped CCD target → monitor device interface path (`DISPLAYCONFIG_TARGET_DEVICE_NAME`) → PnP
//! instance id — never "every monitor but ours", so co-installed third-party virtual displays are
//! untouched.
//! restore. Two precise selectors, never "every monitor but ours" (co-installed third-party
//! virtual displays are untouched): [`disable_for_deactivated`] — monitors on targets the
//! `Exclusive` isolate actually deactivated, mapped CCD target → monitor device interface path
//! (`DISPLAYCONFIG_TARGET_DEVICE_NAME`) → PnP instance id; and [`disable_connected_inactive`] —
//! external physical monitors that are connected but not part of the desktop in ANY topology (the
//! standby-TV case: never active, so the first selector can't see it, yet it probes the link all
//! the same — the exact class in the field reports above).
//!
//! Crash safety: instance ids are journaled to `<config>/pnp-disabled-monitors.json` BEFORE the
//! disable and cleared after a successful re-enable; [`startup_recover`] re-enables leftovers when
@@ -62,7 +65,8 @@ fn write_journal(ids: &[String]) {
/// `\\?\DISPLAY#GSM83CD#5&367fb4cb&0&UID4352#{guid}` → `DISPLAY\GSM83CD\5&367fb4cb&0&UID4352`.
/// The standard device-interface-path → instance-id transform: strip the `\\?\` prefix and the
/// trailing `#{interface-class-guid}`, then `#` separators become `\`.
fn instance_id_from_interface_path(path: &str) -> Option<String> {
// pub(crate): `display_events` applies the same transform to DBT_DEVICEARRIVAL interface paths.
pub(crate) fn instance_id_from_interface_path(path: &str) -> Option<String> {
let rest = path.strip_prefix(r"\\?\")?;
let cut = rest.rfind("#{")?;
Some(rest[..cut].replace('#', "\\"))
@@ -156,8 +160,44 @@ pub fn disable_for_deactivated(
),
}
}
journal_and_disable(targets)
}
/// Disable the devnodes of every EXTERNAL PHYSICAL monitor that is connected but NOT part of the
/// desktop — regardless of who deactivated it. This is the standby-TV case the deactivated-set
/// selection above structurally misses: a TV that was never active has no pre-isolate active path,
/// yet its standby wake events (auto input scan, Instant-On HPD cycling) drive the same Windows
/// reaction cascade. Selection stays allowlist-precise via
/// [`crate::win_display::TargetInventory::external_physical`] — internal panels and
/// indirect/virtual targets (ours or third-party) can never be picked, and `keep_target_ids`
/// (the managed virtual set) is excluded belt-and-braces. Runs AFTER the topology action so the
/// active flags it reads are the settled ones. Journals like [`disable_for_deactivated`]; the
/// caller merges the returned ids into the same teardown list.
pub fn disable_connected_inactive(keep_target_ids: &[u32]) -> Vec<String> {
// SAFETY: `target_inventory` only runs read-only CCD queries over local buffers (see its
// docs); no borrowed memory crosses the call.
let inventory = unsafe { crate::win_display::target_inventory() };
let mut targets: Vec<(String, String)> = Vec::new();
for t in &inventory {
if t.active || !t.external_physical || keep_target_ids.contains(&t.target_id) {
continue;
}
let Some(id) = instance_id_from_interface_path(&t.monitor_device_path) else {
continue;
};
let hit = (id, format!("{} ({})", t.friendly, t.tech));
if !targets.contains(&hit) {
targets.push(hit);
}
}
journal_and_disable(targets)
}
/// Shared tail of the two selectors: crash-journal FIRST, then disable, returning what actually
/// got disabled (the teardown re-enable list).
fn journal_and_disable(targets: Vec<(String, String)>) -> Vec<String> {
if targets.is_empty() {
tracing::info!("PnP-disable: no physical monitor devnodes to disable");
tracing::debug!("PnP-disable: no physical monitor devnodes to disable");
return Vec::new();
}
// Journal FIRST (union with any outstanding ids), so a crash between here and the disable
+9 -3
View File
@@ -370,7 +370,7 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
let session = unsafe { WTSGetActiveConsoleSessionId() };
if session == 0xFFFF_FFFF {
// No interactive session yet (boot / fully logged out). Wait, but wake on stop/session.
tracing::info!("no active console session — waiting");
tracing::debug!("no active console session — waiting");
if wait_any(&[stop, session_ev], 3000) == Some(0) {
break;
}
@@ -388,7 +388,10 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
let child = match unsafe { spawn_host(session, &cmdline, &workdir, job_h) } {
Ok(child) => child,
Err(e) => {
tracing::error!("failed to launch host into session {session}: {e:#}");
tracing::error!(
session,
"failed to launch host into the active console session: {e:#}"
);
if wait_one(stop, 3000) {
break;
}
@@ -452,7 +455,10 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
_ => {
// Child exited on its own — relaunch (with a small crash-loop backoff). The `child`
// drop closes its (already-exited) handles.
tracing::warn!("host process exited — relaunching");
tracing::warn!(
pid = child.pid,
"host process exited on its own — relaunching"
);
}
}
@@ -17,10 +17,20 @@ use windows::core::PCWSTR;
use windows::Win32::Devices::Display::{
DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes,
QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE,
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO,
DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE, DISPLAYCONFIG_PATH_INFO,
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME, QDC_ALL_PATHS,
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME,
DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO,
DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE,
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_COMPONENT_VIDEO,
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_COMPOSITE_VIDEO,
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_DISPLAYPORT_EMBEDDED,
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_DISPLAYPORT_EXTERNAL, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_DVI,
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_HD15, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_HDMI,
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_INTERNAL, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_LVDS,
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_SDI, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_SDTVDONGLE,
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_SVIDEO, DISPLAYCONFIG_OUTPUT_TECHNOLOGY_UDI_EMBEDDED,
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_UDI_EXTERNAL, DISPLAYCONFIG_PATH_INFO,
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
DISPLAYCONFIG_TARGET_DEVICE_NAME, DISPLAYCONFIG_VIDEO_OUTPUT_TECHNOLOGY, QDC_ALL_PATHS,
QDC_ONLY_ACTIVE_PATHS, SDC_ALLOW_CHANGES, SDC_APPLY, SDC_FORCE_MODE_ENUMERATION,
SDC_SAVE_TO_DATABASE, SDC_TOPOLOGY_EXTEND, SDC_USE_SUPPLIED_DISPLAY_CONFIG,
};
@@ -249,7 +259,7 @@ pub(crate) unsafe fn set_advanced_color(target_id: u32, enable: bool) -> bool {
s.header.id = p.targetInfo.id;
s.Anonymous.value = enable as u32; // bit 0 = enableAdvancedColor
let rc = DisplayConfigSetDeviceInfo(&s.header);
tracing::info!(
tracing::debug!(
target_id,
enable,
rc,
@@ -260,7 +270,7 @@ pub(crate) unsafe fn set_advanced_color(target_id: u32, enable: bool) -> bool {
}
tracing::warn!(
target_id,
"set_advanced_color: target not found in active paths"
"virtual-display advanced-color: target not in active paths"
);
false
}
@@ -508,6 +518,124 @@ pub(crate) unsafe fn count_other_active(keep_target_ids: &[u32]) -> Option<u32>
)
}
/// One CONNECTED display target from a full (`QDC_ALL_PATHS`) CCD sweep — the disturbance-
/// attribution inventory. `external_physical` is the load-bearing bit: a standby TV/monitor on a
/// real connector is the prime suspect for the periodic link-probe stutter class, while internal
/// panels and indirect/virtual targets (our own IDD included) are not.
pub(crate) struct TargetInventory {
pub target_id: u32,
/// Whether any active path drives this target (part of the desktop right now).
pub active: bool,
/// External physical connector (HDMI/DP/DVI/…): candidate for standby link-probe churn.
pub external_physical: bool,
/// Short connector label for logs (`"HDMI"`, `"DisplayPort"`, `"internal-panel"`, …).
pub tech: &'static str,
/// The monitor's friendly name (`"LG TV SSCR2"`); empty when the EDID carries none.
pub friendly: String,
/// Monitor device interface path — maps to the PnP instance id (`monitor_devnode`).
pub monitor_device_path: String,
}
/// Classify a CCD output technology: `(external physical?, log label)`. Allowlist, not blocklist:
/// new/unknown/indirect technologies read as non-external, so a co-installed third-party virtual
/// display can never be mistaken for a physical suspect (same precision rule as `monitor_devnode`).
fn output_tech_class(tech: DISPLAYCONFIG_VIDEO_OUTPUT_TECHNOLOGY) -> (bool, &'static str) {
match tech {
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_HDMI => (true, "HDMI"),
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_DISPLAYPORT_EXTERNAL => (true, "DisplayPort"),
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_DVI => (true, "DVI"),
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_HD15 => (true, "VGA"),
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_UDI_EXTERNAL => (true, "UDI"),
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_SDI => (true, "SDI"),
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_COMPONENT_VIDEO => (true, "component"),
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_COMPOSITE_VIDEO => (true, "composite"),
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_SVIDEO => (true, "S-Video"),
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_SDTVDONGLE => (true, "TV-dongle"),
DISPLAYCONFIG_OUTPUT_TECHNOLOGY_INTERNAL
| DISPLAYCONFIG_OUTPUT_TECHNOLOGY_LVDS
| DISPLAYCONFIG_OUTPUT_TECHNOLOGY_DISPLAYPORT_EMBEDDED
| DISPLAYCONFIG_OUTPUT_TECHNOLOGY_UDI_EMBEDDED => (false, "internal-panel"),
_ => (false, "virtual/other"),
}
}
fn utf16z_str(buf: &[u16]) -> String {
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
String::from_utf16_lossy(&buf[..len])
}
/// Sweep EVERY connected display target (`QDC_ALL_PATHS`, deduped from the source×target path
/// matrix to unique targets) with its name, connector class and active state. Read-only CCD; can
/// briefly serialize on the display-config lock during topology churn — callers must keep it OFF
/// the capture thread (`display_events` runs it on its own listener thread and caches).
pub(crate) unsafe fn target_inventory() -> Vec<TargetInventory> {
let mut np = 0u32;
let mut nm = 0u32;
if GetDisplayConfigBufferSizes(QDC_ALL_PATHS, &mut np, &mut nm).is_err() {
return Vec::new();
}
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
if QueryDisplayConfig(
QDC_ALL_PATHS,
&mut np,
paths.as_mut_ptr(),
&mut nm,
modes.as_mut_ptr(),
None,
)
.is_err()
{
return Vec::new();
}
paths.truncate(np as usize);
// Targets driven by an ACTIVE path. `(LUID parts, target id)` keys: target ids are only
// unique per adapter.
let active: Vec<(u32, i32, u32)> = paths
.iter()
.filter(|p| p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0)
.map(|p| {
(
p.targetInfo.adapterId.LowPart,
p.targetInfo.adapterId.HighPart,
p.targetInfo.id,
)
})
.collect();
let mut seen: Vec<(u32, i32, u32)> = Vec::new();
let mut out = Vec::new();
for p in &paths {
let t = &p.targetInfo;
let key = (t.adapterId.LowPart, t.adapterId.HighPart, t.id);
// `targetAvailable` == a monitor is connected; an ACTIVE target is included regardless
// (the flag reads FALSE transiently right after a removal).
if (!t.targetAvailable.as_bool() && !active.contains(&key)) || seen.contains(&key) {
continue;
}
seen.push(key);
let mut req = DISPLAYCONFIG_TARGET_DEVICE_NAME::default();
req.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME;
req.header.size = size_of::<DISPLAYCONFIG_TARGET_DEVICE_NAME>() as u32;
req.header.adapterId = t.adapterId;
req.header.id = t.id;
// `req` is a properly-sized DISPLAYCONFIG_TARGET_DEVICE_NAME local whose header
// (type/size/adapterId/id) is fully initialised; the API writes only within the struct.
if DisplayConfigGetDeviceInfo(&mut req.header) != 0 {
continue; // target with no queryable monitor — nothing to attribute to
}
let (external_physical, tech) = output_tech_class(req.outputTechnology);
out.push(TargetInventory {
target_id: t.id,
active: active.contains(&key),
external_physical,
tech,
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
monitor_device_path: utf16z_str(&req.monitorDevicePath),
});
}
out
}
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
@@ -565,7 +693,7 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<Sav
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
std::thread::sleep(std::time::Duration::from_millis(250));
}
tracing::error!("display isolate (CCD): FAILED to isolate target set {keep_target_ids:?} after 4 attempts — a non-virtual display stayed active (the field-reported exclusive-mode bug)");
tracing::error!("display isolate (CCD): failed to isolate target set {keep_target_ids:?} after 4 attempts — a non-virtual display stayed active (field-reported exclusive-mode bug)");
Some(saved)
}
@@ -754,5 +882,9 @@ pub(crate) unsafe fn restore_displays_ccd(saved: &SavedConfig) {
Some(modes.as_slice()),
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
);
tracing::info!("display isolate (CCD): restored original topology rc={rc:#x}");
if rc == 0 {
tracing::info!("display isolate (CCD): restored original topology");
} else {
tracing::warn!("display isolate (CCD): topology restore failed rc={rc:#x} — physical displays may be left deactivated");
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ impl ksni::Tray for HostTray {
fn status(&self) -> ksni::Status {
match &self.status {
TrayStatus::Error(_) => ksni::Status::NeedsAttention,
s if s.pairing_attention() => ksni::Status::NeedsAttention,
s if s.pairing_attention() || s.has_conflicts() => ksni::Status::NeedsAttention,
_ => ksni::Status::Active,
}
}
+49 -16
View File
@@ -37,6 +37,11 @@ pub struct Summary {
/// host that doesn't send it deserializes as 0.
#[serde(default)]
pub kept_displays: u32,
/// Other Moonlight-compatible hosts (Sunshine/Apollo/…) the host detected on this machine at
/// startup — side-by-side use is unsupported. `#[serde(default)]` so an older host omitting it
/// deserializes as empty.
#[serde(default)]
pub conflicts: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
@@ -69,25 +74,41 @@ impl TrayStatus {
TrayStatus::Starting => "punktfunk host — starting…".into(),
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
TrayStatus::Running(s) => match (&s.session, s.video_streaming) {
(Some(sess), true) => format!(
"punktfunk host {} — streaming {}×{}@{}",
s.version, sess.width, sess.height, sess.fps
),
(_, true) => format!("punktfunk host {} — streaming", s.version),
// Idle, but surface a kept (lingering/pinned) display: it — and, under an exclusive
// topology, your physical monitors — is being held. Release it from the console.
_ if s.kept_displays > 0 => format!(
"punktfunk host {} — idle · {} display{} kept",
s.version,
s.kept_displays,
if s.kept_displays == 1 { "" } else { "s" }
),
_ => format!("punktfunk host {} — idle", s.version),
},
TrayStatus::Running(s) => {
let base = match (&s.session, s.video_streaming) {
(Some(sess), true) => format!(
"punktfunk host {} — streaming {}×{}@{}",
s.version, sess.width, sess.height, sess.fps
),
(_, true) => format!("punktfunk host {} — streaming", s.version),
// Idle, but surface a kept (lingering/pinned) display: it — and, under an
// exclusive topology, your physical monitors — is being held. Release it from
// the console.
_ if s.kept_displays > 0 => format!(
"punktfunk host {} — idle · {} display{} kept",
s.version,
s.kept_displays,
if s.kept_displays == 1 { "" } else { "s" }
),
_ => format!("punktfunk host {} — idle", s.version),
};
// A conflicting Moonlight host (Sunshine/Apollo/…) is the loudest thing to say —
// side-by-side use is unsupported, so lead the tooltip with it.
if s.conflicts.is_empty() {
base
} else {
format!("⚠ conflicting host: {}{base}", s.conflicts.join(", "))
}
}
}
}
/// The host detected another Moonlight-compatible host (Sunshine/Apollo/…) on this machine —
/// unsupported side-by-side. Drives the tray's attention state.
pub fn has_conflicts(&self) -> bool {
matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty())
}
pub fn is_streaming(&self) -> bool {
matches!(self, TrayStatus::Running(s) if s.video_streaming)
}
@@ -445,6 +466,7 @@ mod tests {
pin_pending: false,
pending_approvals: 0,
kept_displays: 0,
conflicts: Vec::new(),
}
}
@@ -487,6 +509,17 @@ mod tests {
}
}
#[test]
fn conflicts_drive_attention_and_lead_the_tooltip() {
let mut s = summary(false);
assert!(!TrayStatus::Running(s.clone()).has_conflicts());
s.conflicts = vec!["Sunshine (running)".into(), "Apollo".into()];
let st = TrayStatus::Running(s);
assert!(st.has_conflicts());
let head = st.headline();
assert!(head.starts_with("⚠ conflicting host: Sunshine (running), Apollo"));
}
#[test]
fn headline_shows_session_and_reason() {
assert_eq!(
+9
View File
@@ -86,6 +86,15 @@ hosts from another machine with `punktfunk-probe --discover`. Where multicast do
Docker/VLAN setups), pass `--no-mdns` (or set `PUNKTFUNK_MDNS=0`) and add the host in the client by
address instead.
## `detect-conflicts`
`punktfunk-host detect-conflicts` reports other Moonlight-compatible hosts (Sunshine, Apollo, and
forks) installed or running on this machine. Running one alongside Punktfunk is **unsupported**
they fight over the same ports and virtual-display driver. Prints what it found and exits **1** if
any conflict exists, **0** if clean (so installers and scripts can gate on it). The host also runs
this check at `serve` startup and surfaces it in the logs, tray, and — on Windows — the installer.
See [Troubleshooting → another streaming host is installed](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed).
## Environment
Most behaviour (compositor, video source, input backend, zero-copy) is set in
+24
View File
@@ -3,6 +3,30 @@ title: Troubleshooting
description: Common problems setting up or using a punktfunk host, and how to fix them.
---
## Another streaming host (Sunshine, Apollo, …) is installed
Punktfunk is a Moonlight-compatible host. So are **Sunshine** and its forks (**Apollo**,
**Vibeshine**, **Vibepollo**, **LuminalShine**, …). Running one of them **at the same time** as
Punktfunk is **not supported**: they bind the *same* GameStream ports (47984/47989 and
4799848010, plus a web UI on 47990 that collides with Punktfunk's management API), advertise the
*same* `_nvstream` mDNS name, and often install a *conflicting virtual-display driver*. The result
is `address already in use` errors, pairing that silently fails, the wrong host answering a client,
and capture/display glitches.
- Punktfunk warns about this automatically: the host logs it at startup (visible in the web
console's **Logs** tab and the system tray tooltip), and the Windows installer warns before
installing. To check on demand, run:
```
punktfunk-host detect-conflicts
```
It lists any conflicting host found (installed or running) and exits non-zero if there is one.
- **Fix:** stop and uninstall the other host, then start Punktfunk — e.g. stop the service
(`sudo systemctl disable --now sunshine` / on Windows `sc stop SunshineService`) and uninstall it.
If you only want to try Punktfunk without removing the other host, at least make sure the other
host is fully **stopped** first (they cannot both run at once).
## The host isn't found on the network
- Make sure the host is actually running (`systemctl --user status punktfunk-host`, or you see it
+3 -1
View File
@@ -25,7 +25,9 @@ packaging/
The other packaging targets have their own READMEs: [`debian/`](debian/README.md) (apt),
[`arch/`](arch/README.md) (pacman binary repo + PKGBUILD + SteamOS sysext),
[`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer +
drivers), plus `kde/` and `linux/` helpers.
drivers), plus `kde/` and `linux/` helpers. **NixOS / Nix** users get a flake (`flake.nix` at the
repo root) with reproducible host + client packages and a `services.punktfunk` NixOS module —
see [`../nix/README.md`](../nix/README.md).
## What's needed beyond base Fedora
+7
View File
@@ -38,6 +38,13 @@ MSG
sudo firewall-cmd --reload
MSG
fi
# Conflicting Moonlight-compatible host (Sunshine/Apollo/...): reuse the host's own detector so
# the warning lives in one place. Exit 1 = found; never fail the install on it.
if command -v punktfunk-host >/dev/null 2>&1; then
if ! conflict="$(punktfunk-host detect-conflicts 2>/dev/null)"; then
printf '\n%s\n' "$conflict"
fi
fi
}
post_upgrade() {
+8
View File
@@ -210,6 +210,14 @@ if [ "$1" = "configure" ]; then
echo " sudo firewall-cmd --permanent --add-service=punktfunk-native && sudo firewall-cmd --reload"
echo " (use punktfunk-gamestream for the Moonlight-compat host)"
fi
# Conflicting Moonlight-compatible host (Sunshine/Apollo/...): reuse the host's own detector so
# the warning lives in one place. Exit 1 = found; never fail the install on it.
if command -v punktfunk-host >/dev/null 2>&1; then
if ! conflict="$(punktfunk-host detect-conflicts 2>/dev/null)"; then
echo ""
echo "$conflict"
fi
fi
fi
exit 0
EOF
+8
View File
@@ -385,6 +385,14 @@ if command -v firewall-cmd >/dev/null 2>&1; then
echo " sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload"
echo " (use punktfunk-native for the native-only host)"
fi
# Conflicting Moonlight-compatible host (Sunshine/Apollo/...): reuse the host's own detector so the
# warning stays in one place. Exit 1 = something found; never fail the install on it.
if command -v punktfunk-host >/dev/null 2>&1; then
if ! conflict="$(punktfunk-host detect-conflicts 2>/dev/null)"; then
echo ""
echo "$conflict"
fi
fi
%if %{with web}
%post web
+36
View File
@@ -285,6 +285,42 @@ Filename: "powershell.exe"; \
#endif
[Code]
{ True if another Moonlight-compatible streaming host is installed - by its SCM service key or its
Program Files directory. Sunshine and its forks all register a "<Name>Service" and install under
Program Files, so a registry + directory probe catches them without enumerating processes (which
pure Pascal can't do cleanly). }
function StreamHostPresent(SvcKey, DirName: String): Boolean;
begin
Result := RegKeyExists(HKLM, 'SYSTEM\CurrentControlSet\Services\' + SvcKey)
or DirExists(ExpandConstant('{commonpf}\' + DirName))
or DirExists(ExpandConstant('{commonpf32}\' + DirName));
end;
{ Runs before any wizard page - the earliest point we can warn. Detect a conflicting host and let
the user abort (default) or continue. Returning False cancels setup. }
function InitializeSetup(): Boolean;
var
Found: String;
begin
Result := True;
Found := '';
if StreamHostPresent('SunshineService', 'Sunshine') then Found := Found + ' - Sunshine' + #13#10;
if StreamHostPresent('ApolloService', 'Apollo') then Found := Found + ' - Apollo' + #13#10;
if StreamHostPresent('VibeshineService', 'Vibeshine') then Found := Found + ' - Vibeshine' + #13#10;
if StreamHostPresent('VibepolloService', 'Vibepollo') then Found := Found + ' - Vibepollo' + #13#10;
if StreamHostPresent('LuminalShineService', 'LuminalShine') then Found := Found + ' - LuminalShine' + #13#10;
if Found <> '' then
Result := MsgBox(
'Another game-streaming host is already installed on this PC:' + #13#10#13#10 + Found + #13#10 +
'Running Punktfunk alongside Sunshine / Apollo / other Moonlight-compatible hosts is NOT ' +
'supported. They bind the same GameStream network ports (47984, 47989, 47998-48010) and ' +
'install a conflicting virtual-display driver, which causes pairing failures, "address ' +
'already in use" errors and capture glitches.' + #13#10#13#10 +
'Stop and uninstall the other host before using Punktfunk.' + #13#10#13#10 +
'Continue with the installation anyway?',
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES;
end;
{ The GameStream task choice, forwarded to `service install` (which writes host.env's
PUNKTFUNK_HOST_CMD - only if it is unset or still one of the two canonical values, so a
hand-customized command line survives upgrades). }