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