From 11045a0f7036b7eef6426a7a87cd8e169947d912 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 12:42:53 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20consolidate=20parallel-session=20WIP?= =?UTF-8?q?=20(HOLD=20=E2=80=94=20do=20not=20push)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 5 + clients/android/native/src/decode.rs | 8 +- crates/pf-client-core/src/audio_wasapi.rs | 4 +- crates/pf-client-core/src/gamepad.rs | 2 +- crates/pf-client-core/src/session.rs | 4 +- crates/pf-client-core/src/video.rs | 10 +- crates/pf-client-core/src/video_pyrowave.rs | 7 +- crates/pf-presenter/src/dmabuf.rs | 2 +- crates/pf-presenter/src/run.rs | 22 +- crates/punktfunk-core/src/client.rs | 6 +- crates/punktfunk-core/src/transport/udp.rs | 3 +- crates/punktfunk-host/Cargo.toml | 3 + crates/punktfunk-host/src/audio/linux/mod.rs | 9 +- .../src/audio/windows/wasapi_cap.rs | 2 +- .../punktfunk-host/src/capture/linux/mod.rs | 36 +- .../src/capture/windows/dxgi.rs | 2 +- .../src/capture/windows/idd_push.rs | 78 +++- crates/punktfunk-host/src/detect.rs | 342 ++++++++++++++++++ crates/punktfunk-host/src/detect/linux.rs | 94 +++++ crates/punktfunk-host/src/detect/windows.rs | 87 +++++ .../src/encode/linux/nvenc_cuda.rs | 47 ++- .../src/encode/linux/pyrowave.rs | 8 +- .../src/encode/linux/vulkan_video.rs | 2 +- .../punktfunk-host/src/encode/windows/amf.rs | 41 ++- .../src/encode/windows/ffmpeg_win.rs | 2 +- .../src/encode/windows/nvenc.rs | 15 +- crates/punktfunk-host/src/gamestream/audio.rs | 6 +- .../punktfunk-host/src/gamestream/control.rs | 12 +- crates/punktfunk-host/src/gamestream/mod.rs | 11 + .../punktfunk-host/src/gamestream/pairing.rs | 4 +- crates/punktfunk-host/src/gamestream/rtsp.rs | 12 +- .../punktfunk-host/src/gamestream/stream.rs | 2 +- crates/punktfunk-host/src/gamestream/tls.rs | 5 +- crates/punktfunk-host/src/gpu.rs | 2 +- crates/punktfunk-host/src/inject.rs | 2 +- .../punktfunk-host/src/inject/linux/libei.rs | 10 +- .../src/inject/linux/steam_controller2.rs | 4 +- .../src/inject/linux/triton_usbip.rs | 4 +- crates/punktfunk-host/src/main.rs | 17 + crates/punktfunk-host/src/mgmt.rs | 8 + crates/punktfunk-host/src/punktfunk1.rs | 44 ++- crates/punktfunk-host/src/vdisplay.rs | 2 +- .../src/vdisplay/linux/gamescope.rs | 2 +- .../punktfunk-host/src/vdisplay/linux/kwin.rs | 2 +- .../src/vdisplay/linux/mutter.rs | 13 +- crates/punktfunk-host/src/vdisplay/policy.rs | 9 +- .../src/vdisplay/windows/manager.rs | 55 ++- .../src/vdisplay/windows/pf_vdisplay.rs | 22 +- crates/punktfunk-host/src/windows/ddc.rs | 6 +- .../src/windows/display_events.rs | 337 +++++++++++++++++ .../src/windows/monitor_devnode.rs | 54 ++- crates/punktfunk-host/src/windows/service.rs | 12 +- .../punktfunk-host/src/windows/win_display.rs | 148 +++++++- crates/punktfunk-tray/src/linux.rs | 2 +- crates/punktfunk-tray/src/status.rs | 65 +++- docs-site/content/docs/host-cli.md | 9 + docs-site/content/docs/troubleshooting.md | 24 ++ packaging/README.md | 4 +- packaging/arch/punktfunk-host.install | 7 + packaging/debian/build-deb.sh | 8 + packaging/rpm/punktfunk.spec | 8 + packaging/windows/punktfunk-host.iss | 36 ++ 62 files changed, 1595 insertions(+), 214 deletions(-) create mode 100644 crates/punktfunk-host/src/detect.rs create mode 100644 crates/punktfunk-host/src/detect/linux.rs create mode 100644 crates/punktfunk-host/src/detect/windows.rs create mode 100644 crates/punktfunk-host/src/windows/display_events.rs diff --git a/.gitignore b/.gitignore index 0c00c4c9..e9410b6f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/clients/android/native/src/decode.rs b/clients/android/native/src/decode.rs index e0715721..30a0b3d7 100644 --- a/clients/android/native/src/decode.rs +++ b/clients/android/native/src/decode.rs @@ -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 }); })), }; diff --git a/crates/pf-client-core/src/audio_wasapi.rs b/crates/pf-client-core/src/audio_wasapi.rs index 32f7ff50..5fb1de9f 100644 --- a/crates/pf-client-core/src/audio_wasapi.rs +++ b/crates/pf-client-core/src/audio_wasapi.rs @@ -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")?; diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index ab869982..a08526f3 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -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()); diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index 816412fc..b6fa23e9 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -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) => {} diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index 07e2cb76..260d3014 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -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; diff --git a/crates/pf-client-core/src/video_pyrowave.rs b/crates/pf-client-core/src/video_pyrowave.rs index f8397981..5e926472 100644 --- a/crates/pf-client-core/src/video_pyrowave.rs +++ b/crates/pf-client-core/src/video_pyrowave.rs @@ -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; diff --git a/crates/pf-presenter/src/dmabuf.rs b/crates/pf-presenter/src/dmabuf.rs index b718794f..7e616459 100644 --- a/crates/pf-presenter/src/dmabuf.rs +++ b/crates/pf-presenter/src/dmabuf.rs @@ -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 diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index cce198f4..0568374f 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -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 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 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 } } diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index b731fb43..670d4870 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -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" + ); } } } diff --git a/crates/punktfunk-core/src/transport/udp.rs b/crates/punktfunk-core/src/transport/udp.rs index 298f8011..4ba460c4 100644 --- a/crates/punktfunk-core/src/transport/udp.rs +++ b/crates/punktfunk-core/src/transport/udp.rs @@ -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 } diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index d19998bb..8a562d3c 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -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 diff --git a/crates/punktfunk-host/src/audio/linux/mod.rs b/crates/punktfunk-host/src/audio/linux/mod.rs index 23a476f0..92529e71 100644 --- a/crates/punktfunk-host/src/audio/linux/mod.rs +++ b/crates/punktfunk-host/src/audio/linux/mod.rs @@ -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 }; diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs index 108c8be5..09f394e9 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs @@ -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")?; diff --git a/crates/punktfunk-host/src/capture/linux/mod.rs b/crates/punktfunk-host/src/capture/linux/mod.rs index d094ee74..89cf5428 100644 --- a/crates/punktfunk-host/src/capture/linux/mod.rs +++ b/crates/punktfunk-host/src/capture/linux/mod.rs @@ -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, diff --git a/crates/punktfunk-host/src/capture/windows/dxgi.rs b/crates/punktfunk-host/src/capture/windows/dxgi.rs index a1180581..3b2c2057 100644 --- a/crates/punktfunk-host/src/capture/windows/dxgi.rs +++ b/crates/punktfunk-host/src/capture/windows/dxgi.rs @@ -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. diff --git a/crates/punktfunk-host/src/capture/windows/idd_push.rs b/crates/punktfunk-host/src/capture/windows/idd_push.rs index 22b0570c..18f48074 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push.rs +++ b/crates/punktfunk-host/src/capture/windows/idd_push.rs @@ -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, ) -> std::result::Result)> { + // 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 diff --git a/crates/punktfunk-host/src/detect.rs b/crates/punktfunk-host/src/detect.rs new file mode 100644 index 00000000..5d412ebb --- /dev/null +++ b/crates/punktfunk-host/src/detect.rs @@ -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 { + Vec::new() + } + pub fn static_evidence(_known: &super::Known) -> Vec { + 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, +} + +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 { + let procs = platform::running_processes(); + let mut out = Vec::new(); + for known in KNOWN { + let mut evidence: Vec = 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> = 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 { + 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::>() + .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) -> 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 + ); + } + } +} diff --git a/crates/punktfunk-host/src/detect/linux.rs b/crates/punktfunk-host/src/detect/linux.rs new file mode 100644 index 00000000..7b13e6b9 --- /dev/null +++ b/crates/punktfunk-host/src/detect/linux.rs @@ -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//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//exe` readlink is needed. +pub fn running_processes() -> Vec { + 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 { + 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 = 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 = 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 { + 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 +} diff --git a/crates/punktfunk-host/src/detect/windows.rs b/crates/punktfunk-host/src/detect/windows.rs new file mode 100644 index 00000000..558474d8 --- /dev/null +++ b/crates/punktfunk-host/src/detect/windows.rs @@ -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 { + 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::() 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 { + 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 { + 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 +} diff --git a/crates/punktfunk-host/src/encode/linux/nvenc_cuda.rs b/crates/punktfunk-host/src/encode/linux/nvenc_cuda.rs index 978e8e9b..e6ff467a 100644 --- a/crates/punktfunk-host/src/encode/linux/nvenc_cuda.rs +++ b/crates/punktfunk-host/src/encode/linux/nvenc_cuda.rs @@ -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, 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; } } } diff --git a/crates/punktfunk-host/src/encode/linux/pyrowave.rs b/crates/punktfunk-host/src/encode/linux/pyrowave.rs index a30c19dd..2af97f1d 100644 --- a/crates/punktfunk-host/src/encode/linux/pyrowave.rs +++ b/crates/punktfunk-host/src/encode/linux/pyrowave.rs @@ -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" diff --git a/crates/punktfunk-host/src/encode/linux/vulkan_video.rs b/crates/punktfunk-host/src/encode/linux/vulkan_video.rs index ada0b924..19afc4a9 100644 --- a/crates/punktfunk-host/src/encode/linux/vulkan_video.rs +++ b/crates/punktfunk-host/src/encode/linux/vulkan_video.rs @@ -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)" ); diff --git a/crates/punktfunk-host/src/encode/windows/amf.rs b/crates/punktfunk-host/src/encode/windows/amf.rs index 8cf64787..db5e8619 100644 --- a/crates/punktfunk-host/src/encode/windows/amf.rs +++ b/crates/punktfunk-host/src/encode/windows/amf.rs @@ -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(()) } diff --git a/crates/punktfunk-host/src/encode/windows/ffmpeg_win.rs b/crates/punktfunk-host/src/encode/windows/ffmpeg_win.rs index 5acef7b1..ebbefd4a 100644 --- a/crates/punktfunk-host/src/encode/windows/ffmpeg_win.rs +++ b/crates/punktfunk-host/src/encode/windows/ffmpeg_win.rs @@ -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 } diff --git a/crates/punktfunk-host/src/encode/windows/nvenc.rs b/crates/punktfunk-host/src/encode/windows/nvenc.rs index f4d2bcbb..6c46cb80 100644 --- a/crates/punktfunk-host/src/encode/windows/nvenc.rs +++ b/crates/punktfunk-host/src/encode/windows/nvenc.rs @@ -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. diff --git a/crates/punktfunk-host/src/gamestream/audio.rs b/crates/punktfunk-host/src/gamestream/audio.rs index d73436f8..75fe796d 100644 --- a/crates/punktfunk-host/src/gamestream/audio.rs +++ b/crates/punktfunk-host/src/gamestream/audio.rs @@ -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). diff --git a/crates/punktfunk-host/src/gamestream/control.rs b/crates/punktfunk-host/src/gamestream/control.rs index a420888e..cebf64ea 100644 --- a/crates/punktfunk-host/src/gamestream/control.rs +++ b/crates/punktfunk-host/src/gamestream/control.rs @@ -101,7 +101,7 @@ pub fn spawn(state: Arc) -> 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) -> 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; diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 02823d62..5154fea0 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -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 \ diff --git a/crates/punktfunk-host/src/gamestream/pairing.rs b/crates/punktfunk-host/src/gamestream/pairing.rs index 841b14e5..e28ebaa2 100644 --- a/crates/punktfunk-host/src/gamestream/pairing.rs +++ b/crates/punktfunk-host/src/gamestream/pairing.rs @@ -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)) diff --git a/crates/punktfunk-host/src/gamestream/rtsp.rs b/crates/punktfunk-host/src/gamestream/rtsp.rs index d0ec589d..340f85f8 100644 --- a/crates/punktfunk-host/src/gamestream/rtsp.rs +++ b/crates/punktfunk-host/src/gamestream/rtsp.rs @@ -97,10 +97,12 @@ fn handle_conn(mut stream: TcpStream, state: Arc) -> 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) -> Option { }; let matches_ours = (hdr && csc >> 1 == 2 || !hdr && csc >> 1 == 1) && csc & 1 == 0; if matches_ours { - tracing::info!( + tracing::debug!( csc, space, range, diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 60b9c059..1c3331e7 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -879,7 +879,7 @@ fn stream_body( "video: streaming (perf)" ); } else { - tracing::info!( + tracing::debug!( fps = fps_count, sent_batches, dropped_batches, diff --git a/crates/punktfunk-host/src/gamestream/tls.rs b/crates/punktfunk-host/src/gamestream/tls.rs index dc83524e..8057003d 100644 --- a/crates/punktfunk-host/src/gamestream/tls.rs +++ b/crates/punktfunk-host/src/gamestream/tls.rs @@ -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; } }; diff --git a/crates/punktfunk-host/src/gpu.rs b/crates/punktfunk-host/src/gpu.rs index f5f2ae3e..7e50ea29 100644 --- a/crates/punktfunk-host/src/gpu.rs +++ b/crates/punktfunk-host/src/gpu.rs @@ -389,7 +389,7 @@ impl GpuPrefStore { Ok(bytes) => match serde_json::from_slice::(&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() } }, diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index f3048396..6a5054de 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -250,7 +250,7 @@ fn injector_service_thread(rx: std::sync::mpsc::Receiver) { 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()); } } diff --git a/crates/punktfunk-host/src/inject/linux/libei.rs b/crates/punktfunk-host/src/inject/linux/libei.rs index dc037437..33f24832 100644 --- a/crates/punktfunk-host/src/inject/linux/libei.rs +++ b/crates/punktfunk-host/src/inject/linux/libei.rs @@ -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"); } } } diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller2.rs b/crates/punktfunk-host/src/inject/linux/steam_controller2.rs index 81b492ef..3b40c358 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller2.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller2.rs @@ -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" ); } diff --git a/crates/punktfunk-host/src/inject/linux/triton_usbip.rs b/crates/punktfunk-host/src/inject/linux/triton_usbip.rs index 238754a6..bbfc6dab 100644 --- a/crates/punktfunk-host/src/inject/linux/triton_usbip.rs +++ b/crates/punktfunk-host/src/inject/linux/triton_usbip.rs @@ -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" ); } diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index af94cccd..cecacde9 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -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()); diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index a4cec12f..50f08e36 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -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, } /// A paired (certificate-pinned) Moonlight client. @@ -1557,6 +1562,9 @@ async fn get_local_summary(State(st): State>) -> Json)); 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 = 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(); } diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 92934801..da45dc2a 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -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 diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs index 90e7afc0..1decebba 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs @@ -1278,7 +1278,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result { // 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" diff --git a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs index c2e08928..5118c5ed 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs @@ -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")?; diff --git a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs b/crates/punktfunk-host/src/vdisplay/linux/mutter.rs index 2edbe3b3..82de2d36 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/mutter.rs @@ -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; } diff --git a/crates/punktfunk-host/src/vdisplay/policy.rs b/crates/punktfunk-host/src/vdisplay/policy.rs index 80500050..115e304d 100644 --- a/crates/punktfunk-host/src/vdisplay/policy.rs +++ b/crates/punktfunk-host/src/vdisplay/policy.rs @@ -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 diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 09fd7512..f2a786e7 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -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 { 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(), diff --git a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs index 5a1f61fd..601ae1cd 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs @@ -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 diff --git a/crates/punktfunk-host/src/windows/ddc.rs b/crates/punktfunk-host/src/windows/ddc.rs index 6c6ca459..0a695164 100644 --- a/crates/punktfunk-host/src/windows/ddc.rs +++ b/crates/punktfunk-host/src/windows/ddc.rs @@ -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 diff --git a/crates/punktfunk-host/src/windows/display_events.rs b/crates/punktfunk-host/src/windows/display_events.rs new file mode 100644 index 00000000..126f41d3 --- /dev/null +++ b/crates/punktfunk-host/src/windows/display_events.rs @@ -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, +} + +#[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, + /// Cached CCD target inventory (see module docs for why the cache exists). + inventory: Vec, +} + +/// 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 { + static STATE: OnceLock> = 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 { + 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 = 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 `" ()"`. Never blocks on the CCD lock. +pub(crate) fn connected_inactive_externals() -> Vec { + 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) { + 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::(), + (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 = "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::() 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); + } + } +} diff --git a/crates/punktfunk-host/src/windows/monitor_devnode.rs b/crates/punktfunk-host/src/windows/monitor_devnode.rs index 24d91134..7a3e6971 100644 --- a/crates/punktfunk-host/src/windows/monitor_devnode.rs +++ b/crates/punktfunk-host/src/windows/monitor_devnode.rs @@ -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 `/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 { +// pub(crate): `display_events` applies the same transform to DBT_DEVICEARRIVAL interface paths. +pub(crate) fn instance_id_from_interface_path(path: &str) -> Option { 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 { + // 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 { 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 diff --git a/crates/punktfunk-host/src/windows/service.rs b/crates/punktfunk-host/src/windows/service.rs index d9d02695..74fab89d 100644 --- a/crates/punktfunk-host/src/windows/service.rs +++ b/crates/punktfunk-host/src/windows/service.rs @@ -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" + ); } } diff --git a/crates/punktfunk-host/src/windows/win_display.rs b/crates/punktfunk-host/src/windows/win_display.rs index 985c1e42..51aa019f 100644 --- a/crates/punktfunk-host/src/windows/win_display.rs +++ b/crates/punktfunk-host/src/windows/win_display.rs @@ -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 ) } +/// 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 { + 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::() 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 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, } } diff --git a/crates/punktfunk-tray/src/status.rs b/crates/punktfunk-tray/src/status.rs index 1750cd22..f69b9914 100644 --- a/crates/punktfunk-tray/src/status.rs +++ b/crates/punktfunk-tray/src/status.rs @@ -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, } #[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!( diff --git a/docs-site/content/docs/host-cli.md b/docs-site/content/docs/host-cli.md index 762bbf9c..d6863cc5 100644 --- a/docs-site/content/docs/host-cli.md +++ b/docs-site/content/docs/host-cli.md @@ -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 diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index 6e277071..b5bf7a89 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -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 +47998–48010, 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 diff --git a/packaging/README.md b/packaging/README.md index efe78bf9..44ed8f3d 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -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 diff --git a/packaging/arch/punktfunk-host.install b/packaging/arch/punktfunk-host.install index ba7883df..18b7e7c0 100644 --- a/packaging/arch/punktfunk-host.install +++ b/packaging/arch/punktfunk-host.install @@ -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() { diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index 9c434412..5392faaf 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -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 diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index 5d6c1979..949a03d1 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -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 diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index 72055788..c3e403c4 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -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 "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). }