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.
|
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
|
||||||
.flatpak-repo/
|
.flatpak-repo/
|
||||||
.flatpak-build/
|
.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;
|
return;
|
||||||
}
|
}
|
||||||
log::info!(
|
log::info!(
|
||||||
"decode: HEVC decoder started at {}x{}",
|
"decode: {mime} decoder started at {}x{}",
|
||||||
mode.width,
|
mode.width,
|
||||||
mode.height
|
mode.height
|
||||||
);
|
);
|
||||||
@@ -815,7 +815,11 @@ fn run_async(
|
|||||||
})),
|
})),
|
||||||
on_error: Some(Box::new(move |e, code, _detail| {
|
on_error: Some(Box::new(move |e, code, _detail| {
|
||||||
let fatal = !code.is_recoverable() && !code.is_transient();
|
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 });
|
let _ = err_tx.send(DecodeEvent::Error { fatal });
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ impl AudioPlayer {
|
|||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = render_thread(pcm_rx, recycle_tx, stop_t, ready_tx, channels as u8)
|
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")?;
|
.context("spawn audio thread")?;
|
||||||
@@ -232,7 +232,7 @@ impl MicStreamer {
|
|||||||
.name("punktfunk-mic".into())
|
.name("punktfunk-mic".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = mic_thread(&connector, stop_t) {
|
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")?;
|
.context("spawn mic thread")?;
|
||||||
|
|||||||
@@ -1493,7 +1493,7 @@ impl Worker {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(pad = slot.index, low, high, error = %e, "rumble: SDL set_rumble failed")
|
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 = (low, high);
|
||||||
slot.rumble.last_at = Some(Instant::now());
|
slot.rumble.last_at = Some(Instant::now());
|
||||||
|
|||||||
@@ -791,7 +791,7 @@ fn spawn_audio(
|
|||||||
buf.extend_from_slice(&pcm[..n]);
|
buf.extend_from_slice(&pcm[..n]);
|
||||||
player.push(buf);
|
player.push(buf);
|
||||||
}
|
}
|
||||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
Err(e) => tracing::debug!(error = %e, "opus decode failed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {}
|
Err(PunktfunkError::NoFrame) => {}
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ impl Decoder {
|
|||||||
if choice == "vaapi" {
|
if choice == "vaapi" {
|
||||||
return Err(e.context("PUNKTFUNK_DECODER=vaapi but VAAPI failed"));
|
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.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||||
self.vaapi_fails = 0;
|
self.vaapi_fails = 0;
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(error = %e,
|
tracing::debug!(backend = which, error = %e,
|
||||||
"{which} decode error — requesting keyframe, keeping hardware decode");
|
"decode error — requesting keyframe, keeping hardware decode");
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
@@ -1653,7 +1653,7 @@ unsafe extern "C" fn pick_vulkan(
|
|||||||
&mut fr,
|
&mut fr,
|
||||||
);
|
);
|
||||||
if r < 0 || fr.is_null() {
|
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;
|
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||||
}
|
}
|
||||||
let fc = (*fr).data as *mut ffi::AVHWFramesContext;
|
let fc = (*fr).data as *mut ffi::AVHWFramesContext;
|
||||||
@@ -1665,7 +1665,7 @@ unsafe extern "C" fn pick_vulkan(
|
|||||||
as _;
|
as _;
|
||||||
let r = ffi::av_hwframe_ctx_init(fr);
|
let r = ffi::av_hwframe_ctx_init(fr);
|
||||||
if r < 0 {
|
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;
|
let mut fr = fr;
|
||||||
ffi::av_buffer_unref(&mut fr);
|
ffi::av_buffer_unref(&mut fr);
|
||||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||||
|
|||||||
@@ -537,7 +537,8 @@ impl PyroWaveDecoder {
|
|||||||
let fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
|
let fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
mode = %format!("{width}x{height}"),
|
width,
|
||||||
|
height,
|
||||||
"PyroWave decoder open on the presenter's device (compute iDWT, BT.709 limited)"
|
"PyroWave decoder open on the presenter's device (compute iDWT, BT.709 limited)"
|
||||||
);
|
);
|
||||||
Ok(PyroWaveDecoder {
|
Ok(PyroWaveDecoder {
|
||||||
@@ -602,8 +603,8 @@ impl PyroWaveDecoder {
|
|||||||
});
|
});
|
||||||
self.next = 0;
|
self.next = 0;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
from = %format!("{}x{}", self.width, self.height),
|
from = %format_args!("{}x{}", self.width, self.height),
|
||||||
to = %format!("{width}x{height}"),
|
to = %format_args!("{width}x{height}"),
|
||||||
"PyroWave decoder rebuilt for mid-stream resize"
|
"PyroWave decoder rebuilt for mid-stream resize"
|
||||||
);
|
);
|
||||||
self.width = width;
|
self.width = width;
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ pub fn import(
|
|||||||
// EGL could leave an INVALID modifier to the driver's implied choice; explicit-
|
// 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).
|
// modifier images can't — LINEAR is the only honest guess (debug-visible if wrong).
|
||||||
let modifier = if frame.modifier == DRM_FORMAT_MOD_INVALID {
|
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
|
DRM_FORMAT_MOD_LINEAR
|
||||||
} else {
|
} else {
|
||||||
frame.modifier
|
frame.modifier
|
||||||
|
|||||||
@@ -184,6 +184,11 @@ struct StreamState {
|
|||||||
// Hardware-path health: a failure streak (or a device with no import support at
|
// 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.
|
// all) demotes the decoder to software via the shared flag — once per session.
|
||||||
dmabuf_demoted: bool,
|
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,
|
hw_fails: u32,
|
||||||
/// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
|
/// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
|
||||||
osd_text: String,
|
osd_text: String,
|
||||||
@@ -255,6 +260,8 @@ impl StreamState {
|
|||||||
win_start: Instant::now(),
|
win_start: Instant::now(),
|
||||||
presented: PresentedWindow::default(),
|
presented: PresentedWindow::default(),
|
||||||
dmabuf_demoted: false,
|
dmabuf_demoted: false,
|
||||||
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
|
pyro_present_warned: false,
|
||||||
hw_fails: 0,
|
hw_fails: 0,
|
||||||
osd_text: String::new(),
|
osd_text: String::new(),
|
||||||
last_stats: None,
|
last_stats: None,
|
||||||
@@ -542,7 +549,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
fullscreen = !fullscreen;
|
fullscreen = !fullscreen;
|
||||||
tracing::debug!(fullscreen, "fullscreen toggle");
|
tracing::debug!(fullscreen, "fullscreen toggle");
|
||||||
if let Err(e) = window.set_fullscreen(fullscreen) {
|
if let Err(e) = window.set_fullscreen(fullscreen) {
|
||||||
tracing::warn!(error = %e, "fullscreen toggle");
|
tracing::warn!(error = %e, fullscreen, "failed to toggle fullscreen");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -985,13 +992,22 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
FrameInput::PyroWave(f),
|
FrameInput::PyroWave(f),
|
||||||
overlay_frame.as_ref(),
|
overlay_frame.as_ref(),
|
||||||
) {
|
) {
|
||||||
Ok(p) => p,
|
Ok(p) => {
|
||||||
|
st.pyro_present_warned = false;
|
||||||
|
p
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if device_lost(&e) {
|
if device_lost(&e) {
|
||||||
return Err(e)
|
return Err(e)
|
||||||
.context("GPU device lost — the session cannot continue");
|
.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
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1941,7 +1941,11 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
ResyncStep::Idle => {}
|
ResyncStep::Idle => {}
|
||||||
}
|
}
|
||||||
} else {
|
} 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);
|
STATE.store(if off { 2 } else { 1 }, Ordering::Relaxed);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
enabled = !off,
|
enabled = !off,
|
||||||
"Windows UDP Send Offload (USO): {} (the 1 Gbps+ send lever; PUNKTFUNK_GSO=0 disables)",
|
"Windows UDP Send Offload (USO) resolved (the 1 Gbps+ send lever; PUNKTFUNK_GSO=0 disables)"
|
||||||
if off { "off" } else { "on" }
|
|
||||||
);
|
);
|
||||||
!off
|
!off
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,9 @@ windows = { version = "0.62", features = [
|
|||||||
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
||||||
"Win32_System_Diagnostics_Debug",
|
"Win32_System_Diagnostics_Debug",
|
||||||
"Win32_System_Kernel",
|
"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
|
# 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
|
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
|
||||||
|
|||||||
@@ -156,7 +156,10 @@ impl PwMicSource {
|
|||||||
.name("punktfunk-pw-mic".into())
|
.name("punktfunk-pw-mic".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = mic_pw_thread(pcm_rx, quit_rx, channels, flush_t, ready_tx) {
|
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.
|
// Whether a clean quit or a daemon death: this instance is done — the pump reopens.
|
||||||
alive_t.store(false, Ordering::Release);
|
alive_t.store(false, Ordering::Release);
|
||||||
@@ -322,7 +325,7 @@ fn mic_pw_thread(
|
|||||||
.state_changed({
|
.state_changed({
|
||||||
let mainloop = mainloop.clone();
|
let mainloop = mainloop.clone();
|
||||||
move |_s, _ud, old, new| {
|
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.
|
// A stream error is unrecoverable for this instance — exit so the pump reopens.
|
||||||
if matches!(new, pw::stream::StreamState::Error(_)) {
|
if matches!(new, pw::stream::StreamState::Error(_)) {
|
||||||
mainloop.quit();
|
mainloop.quit();
|
||||||
@@ -522,7 +525,7 @@ fn pw_thread(
|
|||||||
let _listener = stream
|
let _listener = stream
|
||||||
.add_local_listener_with_user_data(tx)
|
.add_local_listener_with_user_data(tx)
|
||||||
.state_changed(|_s, _ud, old, new| {
|
.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| {
|
.param_changed(|_stream, _tx, id, param| {
|
||||||
let Some(param) = param else { return };
|
let Some(param) = param else { return };
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ impl WasapiLoopbackCapturer {
|
|||||||
.name("punktfunk-wasapi-audio".into())
|
.name("punktfunk-wasapi-audio".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels) {
|
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")?;
|
.context("spawn wasapi audio thread")?;
|
||||||
|
|||||||
@@ -650,8 +650,6 @@ mod pipewire {
|
|||||||
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
|
/// 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.
|
/// so the GPU encoder re-uploads its cursor texture only on change.
|
||||||
serial: u64,
|
serial: u64,
|
||||||
/// One-shot guard for the "cursor present but this frame is zero-copy" notice.
|
|
||||||
warned_zerocopy: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CursorState {
|
impl CursorState {
|
||||||
@@ -1174,22 +1172,6 @@ mod pipewire {
|
|||||||
if ud.broken.load(Ordering::Relaxed) {
|
if ud.broken.load(Ordering::Relaxed) {
|
||||||
return;
|
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
|
// 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
|
// 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
|
// 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);
|
std::sync::atomic::AtomicBool::new(true);
|
||||||
if F2.swap(false, Ordering::Relaxed) {
|
if F2.swap(false, Ordering::Relaxed) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %format!("{e}"),
|
error = %e,
|
||||||
"dmabuf EXPORT_SYNC_FILE failed — no implicit-fence sync; NVIDIA \
|
"dmabuf EXPORT_SYNC_FILE failed — no implicit-fence sync; NVIDIA \
|
||||||
zero-copy may show stale frames (no producer explicit sync)"
|
zero-copy may show stale frames (no producer explicit sync)"
|
||||||
);
|
);
|
||||||
@@ -1915,7 +1897,15 @@ mod pipewire {
|
|||||||
unsafe { stream.queue_raw_buffer(newest) };
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
}));
|
}));
|
||||||
if outcome.is_err() {
|
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()
|
.register()
|
||||||
@@ -1930,7 +1920,11 @@ mod pipewire {
|
|||||||
|
|
||||||
// Request raw video in any encoder-mappable layout, any size/framerate.
|
// Request raw video in any encoder-mappable layout, any size/framerate.
|
||||||
let obj = if let Some((fw, fh)) = fixed_pod {
|
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::pod::object!(
|
||||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||||
pw::spa::param::ParamType::EnumFormat,
|
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.
|
// 100% DuplicateOutput1 E_ACCESSDENIED is diagnosable instead of silent.
|
||||||
match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) {
|
match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) {
|
||||||
Ok(()) => tracing::info!("DPI awareness set: 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"),
|
"SetProcessDpiAwarenessContext failed (already set?) — DuplicateOutput1 may E_ACCESSDENIED"),
|
||||||
}
|
}
|
||||||
// 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). DuplicateOutput1 needs 2.
|
// 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). DuplicateOutput1 needs 2.
|
||||||
|
|||||||
@@ -595,7 +595,7 @@ impl DescriptorPoller {
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
// Degraded, not fatal: the session streams, it just never follows a mid-session
|
// 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).
|
// 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();
|
.ok();
|
||||||
Self { snap, stop, thread }
|
Self { snap, stop, thread }
|
||||||
@@ -753,6 +753,13 @@ pub struct IddPushCapturer {
|
|||||||
/// during active flow and warns when they turn metronomic — the sole-virtual-display
|
/// during active flow and warns when they turn metronomic — the sole-virtual-display
|
||||||
/// periodic-stutter diagnostic.
|
/// periodic-stutter diagnostic.
|
||||||
stall_watch: StallWatch,
|
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
|
/// 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
|
/// 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()`:
|
/// 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,
|
want_444: bool,
|
||||||
keepalive: Box<dyn Send>,
|
keepalive: Box<dyn Send>,
|
||||||
) -> std::result::Result<Self, (anyhow::Error, 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) {
|
match Self::open_inner(target, preferred, client_10bit, want_444) {
|
||||||
Ok(mut me) => {
|
Ok(mut me) => {
|
||||||
me._keepalive = keepalive;
|
me._keepalive = keepalive;
|
||||||
@@ -1146,6 +1156,8 @@ impl IddPushCapturer {
|
|||||||
last_liveness: Instant::now(),
|
last_liveness: Instant::now(),
|
||||||
last_kick: Instant::now(),
|
last_kick: Instant::now(),
|
||||||
stall_watch: StallWatch::new(),
|
stall_watch: StallWatch::new(),
|
||||||
|
stalls_seen: 0,
|
||||||
|
stalls_with_os_events: 0,
|
||||||
out_ring: Vec::new(),
|
out_ring: Vec::new(),
|
||||||
out_idx: 0,
|
out_idx: 0,
|
||||||
video_conv: None,
|
video_conv: None,
|
||||||
@@ -1646,24 +1658,70 @@ impl IddPushCapturer {
|
|||||||
// doesn't read as a DWM stall.
|
// doesn't read as a DWM stall.
|
||||||
self.stall_watch.reset();
|
self.stall_watch.reset();
|
||||||
} else if let Some(stall) = self.stall_watch.note_fresh(now) {
|
} 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;
|
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||||
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||||
// at debug level, and the web-console debug ring captures these.
|
// at debug level, and the web-console debug ring captures these.
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
gap_ms = stall.gap.as_millis() as u64,
|
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 \
|
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||||||
delivered no frame for the gap; the present path stalled below capture"
|
delivered no frame for the gap; the present path stalled below capture"
|
||||||
);
|
);
|
||||||
if let Some(period) = stall.metronomic {
|
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!(
|
tracing::warn!(
|
||||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||||
"capture stalls are METRONOMIC — DWM stops composing the virtual display \
|
os_correlated = correlated,
|
||||||
on a stable period, i.e. a periodic display-path disturbance BELOW \
|
connected_inactive = %suspects,
|
||||||
capture (DWM present clock / GPU driver / display-poller software). \
|
"capture stalls are METRONOMIC and coincide with Windows monitor \
|
||||||
Correlate with 'slow display-descriptor poll'; if that never fires, the \
|
hot-plug/re-enumeration events — a connected display (or its \
|
||||||
disturbance is outside punktfunk — try display topology=primary or \
|
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
|
||||||
extend (keep a physical output active), or a different refresh rate"
|
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
|
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(|| {
|
.get_or_init(|| {
|
||||||
let table = load_api();
|
let table = load_api();
|
||||||
if let Err(e) = &table {
|
if let Err(e) = &table {
|
||||||
tracing::warn!("NVENC (Linux direct) API unavailable: {e}");
|
tracing::warn!(error = %e, "NVENC (Linux direct) API unavailable");
|
||||||
}
|
}
|
||||||
table
|
table
|
||||||
})
|
})
|
||||||
@@ -318,6 +318,11 @@ pub struct NvencCudaEncoder {
|
|||||||
cursor: Option<cuda::CursorBlend>,
|
cursor: Option<cuda::CursorBlend>,
|
||||||
cursor_tried: bool,
|
cursor_tried: bool,
|
||||||
cursor_serial: u64,
|
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
|
/// 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.
|
/// burst (the session loop's bounded encoder resets) logs the diagnosis once, not per attempt.
|
||||||
diagnosed: bool,
|
diagnosed: bool,
|
||||||
@@ -386,6 +391,8 @@ impl NvencCudaEncoder {
|
|||||||
cursor: None,
|
cursor: None,
|
||||||
cursor_tried: false,
|
cursor_tried: false,
|
||||||
cursor_serial: u64::MAX,
|
cursor_serial: u64::MAX,
|
||||||
|
cursor_upload_warned: false,
|
||||||
|
cursor_blend_warned: false,
|
||||||
diagnosed: false,
|
diagnosed: false,
|
||||||
inited: false,
|
inited: false,
|
||||||
rfi_supported: false,
|
rfi_supported: false,
|
||||||
@@ -946,14 +953,12 @@ impl NvencCudaEncoder {
|
|||||||
|
|
||||||
self.inited = true;
|
self.inited = true;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"NVENC CUDA session: {}x{}@{} {}-bit {} Mbps {:?} fmt={:?}",
|
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
|
||||||
self.width,
|
bit_depth = self.bit_depth,
|
||||||
self.height,
|
mbps = self.bitrate_bps / 1_000_000,
|
||||||
self.fps,
|
codec = ?self.codec_guid,
|
||||||
self.bit_depth,
|
fmt = ?self.buffer_fmt,
|
||||||
self.bitrate_bps / 1_000_000,
|
"NVENC CUDA session ready"
|
||||||
self.codec_guid,
|
|
||||||
self.buffer_fmt,
|
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1058,9 +1063,19 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
if let Some(cb) = &self.cursor {
|
if let Some(cb) = &self.cursor {
|
||||||
if self.cursor_serial != ov.serial {
|
if self.cursor_serial != ov.serial {
|
||||||
match cb.upload(ov.rgba.as_slice(), ov.w, ov.h) {
|
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) => {
|
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),
|
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y),
|
||||||
};
|
};
|
||||||
if let Err(e) = r {
|
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,
|
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||||
};
|
};
|
||||||
let mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
let mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
||||||
if pw::pyrowave_encoder_create(&einfo, &mut enc) != pw::pyrowave_result_PYROWAVE_SUCCESS
|
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
|
||||||
{
|
if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
|
||||||
tracing::error!("pyrowave: encoder rebuild failed");
|
tracing::error!(result = ?r, "pyrowave: encoder rebuild failed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.pw_enc = enc;
|
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
|
// (plan §4.6 — wavelet quality collapses well above the AIMD floor); until then this
|
||||||
// faithfully applies whatever the caller asks.
|
// faithfully applies whatever the caller asks.
|
||||||
self.frame_budget = budget_for(bps, self.fps);
|
self.frame_budget = budget_for(bps, self.fps);
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
mbps = bps / 1_000_000,
|
mbps = bps / 1_000_000,
|
||||||
budget_kib = self.frame_budget / 1024,
|
budget_kib = self.frame_budget / 1024,
|
||||||
"pyrowave: per-frame rate budget retargeted in place"
|
"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
|
// 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.
|
// session's RC state IS the new rate from this frame on — later begins declare it.
|
||||||
self.bitrate = nb;
|
self.bitrate = nb;
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
mbps = nb / 1_000_000,
|
mbps = nb / 1_000_000,
|
||||||
"vulkan-encode: rate control retargeted in place (no IDR)"
|
"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();
|
let lib = load_factory();
|
||||||
if let Err(e) = &lib {
|
if let Err(e) = &lib {
|
||||||
// Once per process; only reachable when the backend resolved to AMF on this box.
|
// 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
|
lib
|
||||||
})
|
})
|
||||||
@@ -1101,7 +1101,8 @@ unsafe fn set_prop(
|
|||||||
} else {
|
} else {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
property = %name,
|
property = %name,
|
||||||
result = %format!("{} ({r})", result_name(r)),
|
result = result_name(r),
|
||||||
|
amf_code = r,
|
||||||
"optional AMF encoder property rejected (VCN generation/driver) — continuing"
|
"optional AMF encoder property rejected (VCN generation/driver) — continuing"
|
||||||
);
|
);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
@@ -1666,15 +1667,18 @@ impl AmfEncoder {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
codec = ?self.codec,
|
codec = ?self.codec,
|
||||||
context = context_no,
|
context = context_no,
|
||||||
device = format!("{:#x}", device.as_raw() as usize),
|
device = %format_args!("{:#x}", device.as_raw() as usize),
|
||||||
"native AMF encode active (context #{context_no}, {}x{}@{}, zero-copy D3D11 {} ring, runtime {}.{}.{})",
|
width = self.width,
|
||||||
self.width,
|
height = self.height,
|
||||||
self.height,
|
fps = self.fps,
|
||||||
self.fps,
|
ring = if self.ten_bit { "P010" } else { "NV12" },
|
||||||
if self.ten_bit { "P010" } else { "NV12" },
|
runtime = %format_args!(
|
||||||
|
"{}.{}.{}",
|
||||||
(lib.version >> 48) & 0xffff,
|
(lib.version >> 48) & 0xffff,
|
||||||
(lib.version >> 32) & 0xffff,
|
(lib.version >> 32) & 0xffff,
|
||||||
(lib.version >> 16) & 0xffff,
|
(lib.version >> 16) & 0xffff
|
||||||
|
),
|
||||||
|
"native AMF encode active (zero-copy D3D11)"
|
||||||
);
|
);
|
||||||
self.inner = Some(Inner {
|
self.inner = Some(Inner {
|
||||||
comp,
|
comp,
|
||||||
@@ -2147,7 +2151,8 @@ impl Encoder for AmfEncoder {
|
|||||||
);
|
);
|
||||||
if r != sys::AMF_OK {
|
if r != sys::AMF_OK {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
result = %format!("{} ({r})", result_name(r)),
|
result = result_name(r),
|
||||||
|
amf_code = r,
|
||||||
"AMF forced-keyframe picture type rejected"
|
"AMF forced-keyframe picture type rejected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2192,7 +2197,8 @@ impl Encoder for AmfEncoder {
|
|||||||
if r != sys::AMF_OK {
|
if r != sys::AMF_OK {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
slot,
|
slot,
|
||||||
result = %format!("{} ({r})", result_name(r)),
|
result = result_name(r),
|
||||||
|
amf_code = r,
|
||||||
"AMF LTR mark rejected"
|
"AMF LTR mark rejected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2212,7 +2218,8 @@ impl Encoder for AmfEncoder {
|
|||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
slot,
|
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"
|
"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).
|
// end-of-stream (remaining AUs then surface through `poll` until AMF_EOF).
|
||||||
let r = unsafe { ((*(*inner.comp.0).vtbl).drain)(inner.comp.0) };
|
let r = unsafe { ((*(*inner.comp.0).vtbl).drain)(inner.comp.0) };
|
||||||
if r != sys::AMF_OK {
|
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(())
|
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
|
/// `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.)
|
/// Welcome. (Follow-up: implement + validate on an RDNA3+/Arc Windows box.)
|
||||||
pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
|
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
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
|
|||||||
if let Err(e) = &table {
|
if let Err(e) = &table {
|
||||||
// Once per process. Only reachable when something resolved to NVENC on this box
|
// 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.
|
// (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
|
table
|
||||||
})
|
})
|
||||||
@@ -1049,11 +1049,11 @@ impl NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
|
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
split_mode,
|
split_mode,
|
||||||
bit_depth = self.bit_depth,
|
bit_depth = self.bit_depth,
|
||||||
pixel_rate,
|
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
|
// 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
|
// `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).
|
// mode (a split session occupies one hardware session per engine).
|
||||||
self.session_units = split_mode_units(split_mode);
|
self.session_units = split_mode_units(split_mode);
|
||||||
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||||
if self.bitrate_bps < requested_bps {
|
// (The clamp path above already logs the requested→clamped bitrate at warn; no second
|
||||||
tracing::info!(
|
// info line for the same event here.)
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. one output bitstream per in-flight slot. There is NO encoder-owned input pool: the
|
// 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.
|
// 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);
|
punktfunk_core::transport::grow_socket_buffers(&sock);
|
||||||
// The client pings the audio port (~every 500ms) so we learn where to send.
|
// The client pings the audio port (~every 500ms) so we learn where to send.
|
||||||
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
|
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 mut probe = [0u8; 256];
|
||||||
let (_, client) = sock
|
let (_, client) = sock
|
||||||
.recv_from(&mut probe)
|
.recv_from(&mut probe)
|
||||||
@@ -275,7 +275,7 @@ fn run(
|
|||||||
&sock,
|
&sock,
|
||||||
punktfunk_core::transport::MediaClass::Audio,
|
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
|
// 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.
|
// 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);
|
timestamp = timestamp.wrapping_add(frame_ms as u32);
|
||||||
sent += 1;
|
sent += 1;
|
||||||
if sent % 400 == 0 {
|
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).
|
// 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,
|
Ok(None) => break,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %format!("{e:?}"), "control: service error");
|
tracing::warn!(error = ?e, "control: service error");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
|||||||
for wire in out {
|
for wire in out {
|
||||||
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
|
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 inner == 0x0301 {
|
||||||
if let Some((first, last)) = decode_rfi_range(&pt) {
|
if let Some((first, last)) = decode_rfi_range(&pt) {
|
||||||
*state.rfi_range.lock().unwrap() = Some((first, last));
|
*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 {
|
} else {
|
||||||
state
|
state
|
||||||
.force_idr
|
.force_idr
|
||||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
tracing::info!("control: RFI request (no range) → keyframe");
|
tracing::debug!("control: RFI request (no range) → keyframe");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -227,8 +227,8 @@ fn on_receive(
|
|||||||
state
|
state
|
||||||
.force_idr
|
.force_idr
|
||||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
ty = format!("{inner:#06x}"),
|
ty = %format_args!("{inner:#06x}"),
|
||||||
"control: IDR request → keyframe"
|
"control: IDR request → keyframe"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -218,6 +218,17 @@ pub fn serve(
|
|||||||
gamestream,
|
gamestream,
|
||||||
"punktfunk host"
|
"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 {
|
if gamestream {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"GameStream/Moonlight compat ENABLED (--gamestream): its pairing runs over plain HTTP and \
|
"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());
|
store.push(s.client_cert_der.clone());
|
||||||
super::save_paired(&store);
|
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))
|
Ok(paired_xml("", true))
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
uniqueid,
|
uniqueid,
|
||||||
hash_ok,
|
hash_ok,
|
||||||
sig_ok,
|
sig_ok,
|
||||||
"pairing phase 4 — FAILED (PIN/cert)"
|
"pairing phase 4 rejected — PIN or cert mismatch"
|
||||||
);
|
);
|
||||||
map.remove(uniqueid);
|
map.remove(uniqueid);
|
||||||
Ok(paired_xml("", false))
|
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
|
// 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.
|
// the end of the response). Session state lives in `AppState`, not the connection.
|
||||||
if let Some(req) = read_message(&mut stream, &mut buf)? {
|
if let Some(req) = read_message(&mut stream, &mut buf)? {
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
method = %req.method, cseq = %req.cseq,
|
method = %req.method,
|
||||||
"RTSP {} | {}", req.head.replace("\r\n", " | "),
|
cseq = %req.cseq,
|
||||||
if req.body.is_empty() { String::new() } else { format!("body: {}", req.body.replace("\r\n", " | ")) }
|
headers = %req.head.replace("\r\n", " | "),
|
||||||
|
body = %req.body.replace("\r\n", " | "),
|
||||||
|
"RTSP request"
|
||||||
);
|
);
|
||||||
let resp = handle_request(&req, &state, peer);
|
let resp = handle_request(&req, &state, peer);
|
||||||
stream.write_all(resp.as_bytes()).context("RTSP write")?;
|
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;
|
let matches_ours = (hdr && csc >> 1 == 2 || !hdr && csc >> 1 == 1) && csc & 1 == 0;
|
||||||
if matches_ours {
|
if matches_ours {
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
csc,
|
csc,
|
||||||
space,
|
space,
|
||||||
range,
|
range,
|
||||||
|
|||||||
@@ -879,7 +879,7 @@ fn stream_body(
|
|||||||
"video: streaming (perf)"
|
"video: streaming (perf)"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
fps = fps_count,
|
fps = fps_count,
|
||||||
sent_batches,
|
sent_batches,
|
||||||
dropped_batches,
|
dropped_batches,
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ pub(crate) async fn serve_https(
|
|||||||
let (tcp, peer) = match listener.accept().await {
|
let (tcp, peer) = match listener.accept().await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ impl GpuPrefStore {
|
|||||||
Ok(bytes) => match serde_json::from_slice::<GpuPreference>(&bytes) {
|
Ok(bytes) => match serde_json::from_slice::<GpuPreference>(&bytes) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
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()
|
GpuPreference::default()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ fn injector_service_thread(rx: std::sync::mpsc::Receiver<InputEvent>) {
|
|||||||
last_failed = None;
|
last_failed = None;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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());
|
last_failed = Some(std::time::Instant::now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -569,7 +569,7 @@ impl EiState {
|
|||||||
?cap,
|
?cap,
|
||||||
devices = self.devices.len(),
|
devices = self.devices.len(),
|
||||||
resumed = self.devices.iter().filter(|d| d.resumed).count(),
|
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
|
// 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());
|
dev.frame(self.last_serial, self.now_us());
|
||||||
}
|
}
|
||||||
if let Err(e) = ctx.flush() {
|
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");
|
tracing::warn!(error = %e, "libei: ctx.flush failed");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if loud {
|
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);
|
let reply = triton_feature_reply(&self.last_set, &self.serial, self.unit_id);
|
||||||
if reply[1] != self.last_get_logged {
|
if reply[1] != self.last_get_logged {
|
||||||
self.last_get_logged = reply[1];
|
self.last_get_logged = reply[1];
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
cmd = format!("{:#04x}", reply[1]),
|
cmd = %format_args!("{:#04x}", reply[1]),
|
||||||
"virtual SC2: answering feature GET"
|
"virtual SC2: answering feature GET"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,8 +259,8 @@ impl UsbInterfaceHandler for TritonHandler {
|
|||||||
};
|
};
|
||||||
if reply[1] != self.last_get_logged {
|
if reply[1] != self.last_get_logged {
|
||||||
self.last_get_logged = reply[1];
|
self.last_get_logged = reply[1];
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
cmd = format!("{:#04x}", reply[1]),
|
cmd = %format_args!("{:#04x}", reply[1]),
|
||||||
"virtual SC2 usbip: answering feature GET"
|
"virtual SC2 usbip: answering feature GET"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
mod audio;
|
mod audio;
|
||||||
mod capture;
|
mod capture;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod detect;
|
||||||
mod discovery;
|
mod discovery;
|
||||||
mod wol;
|
mod wol;
|
||||||
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
|
// 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")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "windows/ddc.rs"]
|
#[path = "windows/ddc.rs"]
|
||||||
mod ddc;
|
mod ddc;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/display_events.rs"]
|
||||||
|
mod display_events;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "linux/dmabuf_fence.rs"]
|
#[path = "linux/dmabuf_fence.rs"]
|
||||||
mod dmabuf_fence;
|
mod dmabuf_fence;
|
||||||
@@ -211,6 +215,19 @@ fn real_main() -> Result<()> {
|
|||||||
monitor_devnode::startup_recover();
|
monitor_devnode::startup_recover();
|
||||||
gamestream::serve(mgmt_opts, native, gamestream)
|
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).
|
// Print the management API's OpenAPI document (for client codegen).
|
||||||
Some("openapi") => {
|
Some("openapi") => {
|
||||||
print!("{}", mgmt::openapi_json());
|
print!("{}", mgmt::openapi_json());
|
||||||
|
|||||||
@@ -419,6 +419,11 @@ struct LocalSummary {
|
|||||||
/// (`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is
|
/// (`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.
|
/// held; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.
|
||||||
kept_displays: u32,
|
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.
|
/// A paired (certificate-pinned) Moonlight client.
|
||||||
@@ -1557,6 +1562,9 @@ async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummar
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|d| d.state == "lingering" || d.state == "pinned")
|
.filter(|d| d.state == "lingering" || d.state == "pinned")
|
||||||
.count() as u32,
|
.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!(
|
tracing::info!(
|
||||||
paired = st.paired_clients,
|
paired = st.paired_clients,
|
||||||
require = opts.require_pairing,
|
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 last_pairing = Arc::new(std::sync::Mutex::new(None::<std::time::Instant>));
|
||||||
let opts = Arc::new(opts);
|
let opts = Arc::new(opts);
|
||||||
@@ -613,7 +617,7 @@ async fn pair_ceremony(
|
|||||||
}
|
}
|
||||||
tracing::info!(name = %req.name, "pairing complete — client trusted");
|
tracing::info!(name = %req.name, "pairing complete — client trusted");
|
||||||
} else {
|
} 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?;
|
io::write_msg(&mut send, &PairResult { ok }.encode()).await?;
|
||||||
let _ = send.finish();
|
let _ = send.finish();
|
||||||
@@ -1308,7 +1312,7 @@ async fn serve_session(
|
|||||||
let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1));
|
let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1));
|
||||||
fec_target_ctl.store(target, Ordering::Relaxed);
|
fec_target_ctl.store(target, Ordering::Relaxed);
|
||||||
if prev != target {
|
if prev != target {
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
loss_ppm = rep.loss_ppm,
|
loss_ppm = rep.loss_ppm,
|
||||||
fec_pct = target,
|
fec_pct = target,
|
||||||
prev_fec_pct = prev,
|
prev_fec_pct = prev,
|
||||||
@@ -1336,7 +1340,7 @@ async fn serve_session(
|
|||||||
} else {
|
} else {
|
||||||
resolve_bitrate_kbps(req.bitrate_kbps)
|
resolve_bitrate_kbps(req.bitrate_kbps)
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
requested_kbps = req.bitrate_kbps,
|
requested_kbps = req.bitrate_kbps,
|
||||||
resolved_kbps = resolved,
|
resolved_kbps = resolved,
|
||||||
"mid-stream bitrate change requested"
|
"mid-stream bitrate change requested"
|
||||||
@@ -1521,7 +1525,7 @@ async fn serve_session(
|
|||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("punktfunk1-audio".into())
|
.name("punktfunk1-audio".into())
|
||||||
.spawn(move || audio_thread(conn, stop, cap, channels))
|
.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()
|
.ok()
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -2589,7 +2593,7 @@ fn input_thread(
|
|||||||
// Log the silent→active transition (once per buzz) so a live test can tell
|
// 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".
|
// "host never gets rumble from the game" apart from "client doesn't render it".
|
||||||
if prev == (0, 0) && (low != 0 || high != 0) {
|
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_state[idx] = (low, high);
|
||||||
rumble_seen[idx] = true;
|
rumble_seen[idx] = true;
|
||||||
@@ -2785,7 +2789,7 @@ fn audio_thread(
|
|||||||
let mut enc = match NativeAudioEnc::new(want) {
|
let mut enc = match NativeAudioEnc::new(want) {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(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);
|
*audio_cap.lock().unwrap() = Some(capturer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2804,6 +2808,9 @@ fn audio_thread(
|
|||||||
// restart). The first open already happened above; failing THAT still ends the session quietly.
|
// restart). The first open already happened above; failing THAT still ends the session quietly.
|
||||||
let mut capturer = Some(capturer);
|
let mut capturer = Some(capturer);
|
||||||
let mut last_failed: Option<std::time::Instant> = None;
|
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!(
|
tracing::info!(
|
||||||
channels = want,
|
channels = want,
|
||||||
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
|
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
|
||||||
@@ -2851,7 +2858,16 @@ fn audio_thread(
|
|||||||
}
|
}
|
||||||
seq = seq.wrapping_add(1);
|
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) {
|
match SetThreadPriority(GetCurrentThread(), prio) {
|
||||||
Ok(()) => tracing::debug!(critical, "thread priority raised"),
|
Ok(()) => tracing::debug!(critical, "thread priority raised"),
|
||||||
Err(e) => {
|
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 {
|
} else {
|
||||||
"transient"
|
"transient"
|
||||||
};
|
};
|
||||||
tracing::error!(error = %chain, kind,
|
tracing::warn!(error = %chain, kind,
|
||||||
"session-switch rebuild failed — staying on the current backend");
|
"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
|
last_forced_idr = Some(std::time::Instant::now()); // fresh encoder opens on an IDR — anchor the cooldown
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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");
|
"mode-switch rebuild failed — staying on the current mode");
|
||||||
// H2 rollback: the control task acked the switch BEFORE this rebuild, so the
|
// 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
|
// 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());
|
last_forced_idr = Some(std::time::Instant::now());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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");
|
"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)");
|
session (see the error above for the cause)");
|
||||||
return Err(e).context("encoder submit");
|
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,
|
max = MAX_ENCODER_RESETS,
|
||||||
"encoder submit failed — encoder rebuilt in place, forcing an IDR");
|
"encoder submit failed — encoder rebuilt in place, forcing an IDR");
|
||||||
last_au_at = std::time::Instant::now();
|
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}")))
|
return Err(poll_err.unwrap_or_else(|| anyhow!("{why}")))
|
||||||
.context("encoder stalled — in-place rebuild unavailable or exhausted");
|
.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");
|
"encode stall detected — encoder rebuilt in place, forcing an IDR");
|
||||||
last_au_at = std::time::Instant::now();
|
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() {
|
if gamescope::is_available() {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(
|
tracing::warn!(
|
||||||
"game_session=dedicated but gamescope is unavailable — falling back to auto routing"
|
"game_session=dedicated but gamescope is unavailable — falling back to auto routing"
|
||||||
);
|
);
|
||||||
false
|
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
|
// 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.
|
// wait on a corpse. Re-run the unit so every readiness attempt inside the deadline is used.
|
||||||
if !unit_starting_or_active(unit_name) {
|
if !unit_starting_or_active(unit_name) {
|
||||||
tracing::info!(
|
tracing::warn!(
|
||||||
unit = unit_name,
|
unit = unit_name,
|
||||||
"gamescope session: transient unit died (missed the wrapper's 5 s gamescope \
|
"gamescope session: transient unit died (missed the wrapper's 5 s gamescope \
|
||||||
readiness window?) — relaunching"
|
readiness window?) — relaunching"
|
||||||
|
|||||||
@@ -685,7 +685,7 @@ fn run(
|
|||||||
.dispatch_pending(&mut state)
|
.dispatch_pending(&mut state)
|
||||||
.context("dispatch_pending")?;
|
.context("dispatch_pending")?;
|
||||||
if state.closed {
|
if state.closed {
|
||||||
tracing::warn!("KWin closed the virtual-output stream");
|
tracing::warn!(output = %name, node_id, "KWin closed the virtual-output stream");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
conn.flush().context("wayland flush")?;
|
conn.flush().context("wayland flush")?;
|
||||||
|
|||||||
@@ -255,12 +255,12 @@ fn session_thread(
|
|||||||
Ok(dc) => match get_state(&dc).await {
|
Ok(dc) => match get_state(&dc).await {
|
||||||
Ok(state) => Some((dc, state)),
|
Ok(state) => Some((dc, state)),
|
||||||
Err(e) => {
|
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
|
None
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(e) => {
|
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
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -301,14 +301,16 @@ fn session_thread(
|
|||||||
if exclusive { "disabled" } else { "kept" }
|
if exclusive { "disabled" } else { "kept" }
|
||||||
),
|
),
|
||||||
Err(e) => tracing::warn!(
|
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));
|
tracked = Some((dc, pre, vconn));
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!(
|
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!(
|
tracing::warn!(
|
||||||
scale,
|
scale,
|
||||||
derived,
|
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;
|
scale = derived;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,9 +234,12 @@ pub struct DisplayPolicy {
|
|||||||
/// untouched.
|
/// untouched.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ddc_power_off: bool,
|
pub ddc_power_off: bool,
|
||||||
/// EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,
|
/// EXPERIMENTAL (Windows): DISABLE physical monitors' PnP device nodes for the stream's
|
||||||
/// additionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose
|
/// duration (persistently, so a standby monitor/TV whose hot-plug events re-arrive stays
|
||||||
/// hot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same
|
/// 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
|
/// "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
|
/// 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
|
/// re-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host
|
||||||
|
|||||||
@@ -906,8 +906,9 @@ impl VirtualDisplayManager {
|
|||||||
Some(n) => {
|
Some(n) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
backend = self.driver.name(),
|
backend = self.driver.name(),
|
||||||
"target {} -> {n}",
|
target_id = added.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.
|
// ADD only advertises the mode; force it active so DXGI captures the requested size.
|
||||||
set_active_mode(n, mode);
|
set_active_mode(n, mode);
|
||||||
@@ -1015,6 +1016,26 @@ impl VirtualDisplayManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
|
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!(
|
None => tracing::warn!(
|
||||||
"virtual-display target {} not yet an active display path (auto-activate, EXTEND \
|
"virtual-display target {} not yet an active display path (auto-activate, EXTEND \
|
||||||
@@ -1067,8 +1088,11 @@ impl VirtualDisplayManager {
|
|||||||
) -> Result<Monitor> {
|
) -> Result<Monitor> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
slot,
|
slot,
|
||||||
old = %format!("{}x{}@{}", old.mode.width, old.mode.height, old.mode.refresh_hz),
|
old = format!(
|
||||||
new = %format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
|
"{}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,
|
old_target = old.target_id,
|
||||||
"virtual-display: re-arriving monitor for a mid-stream resize (exact mode)"
|
"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
|
// (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).
|
// — first-in captured it, last-out restores it (design §6.1).
|
||||||
self.stop_pinger();
|
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
|
// 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).
|
// 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);
|
let pnp_disabled = std::mem::take(&mut inner.group.pnp_disabled);
|
||||||
if !pnp_disabled.is_empty() {
|
if !pnp_disabled.is_empty() {
|
||||||
crate::monitor_devnode::enable_instances(&pnp_disabled);
|
crate::monitor_devnode::enable_instances(&pnp_disabled);
|
||||||
thread::sleep(Duration::from_millis(300));
|
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);
|
restore_displays_ccd(&saved);
|
||||||
// EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and
|
// 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
|
// 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) {
|
if is_device_gone(&e) {
|
||||||
self.invalidate_device(&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 {
|
} else {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
backend = self.driver.name(),
|
backend = self.driver.name(),
|
||||||
|
|||||||
@@ -164,10 +164,14 @@ fn restart_vdisplay_device() -> bool {
|
|||||||
{
|
{
|
||||||
Ok(o) => {
|
Ok(o) => {
|
||||||
let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
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!(
|
tracing::warn!(
|
||||||
%status,
|
%status,
|
||||||
"pf-vdisplay: cycled the adapter device (hostless-zombie recovery)"
|
"pf-vdisplay: cycled the adapter device (hostless-zombie recovery)"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
status != "ABSENT"
|
status != "ABSENT"
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -531,13 +535,13 @@ impl VdisplayDriver for PfVdisplayDriver {
|
|||||||
HighPart: reply.adapter_luid_high,
|
HighPart: reply.adapter_luid_high,
|
||||||
};
|
};
|
||||||
tracing::info!(
|
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.width,
|
||||||
mode.height,
|
mode.height,
|
||||||
mode.refresh_hz,
|
mode.refresh_hz
|
||||||
reply.target_id,
|
|
||||||
luid.LowPart,
|
|
||||||
reply.wudf_pid
|
|
||||||
);
|
);
|
||||||
// Per-client identity diagnostic: did the driver honor the host's preferred (stable) monitor id?
|
// 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
|
// 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);
|
acked += set_power(m.hmon, &m.device, POWER_OFF);
|
||||||
}
|
}
|
||||||
if acked == 0 {
|
if acked == 0 {
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
"DDC/CI: no panel accepted the off command — the experiment is a no-op on this box \
|
"DDC/CI: no physical panel accepted the DPMS-off command \
|
||||||
(monitors without DDC/CI, or none besides the virtual display)"
|
(no DDC/CI-capable panel besides the virtual display)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
acked
|
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
|
//! (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.
|
//! 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
|
//! (`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
|
//! 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,
|
//! restore. Two precise selectors, never "every monitor but ours" (co-installed third-party
|
||||||
//! mapped CCD target → monitor device interface path (`DISPLAYCONFIG_TARGET_DEVICE_NAME`) → PnP
|
//! virtual displays are untouched): [`disable_for_deactivated`] — monitors on targets the
|
||||||
//! instance id — never "every monitor but ours", so co-installed third-party virtual displays are
|
//! `Exclusive` isolate actually deactivated, mapped CCD target → monitor device interface path
|
||||||
//! untouched.
|
//! (`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
|
//! 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
|
//! 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`.
|
/// `\\?\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
|
/// The standard device-interface-path → instance-id transform: strip the `\\?\` prefix and the
|
||||||
/// trailing `#{interface-class-guid}`, then `#` separators become `\`.
|
/// 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 rest = path.strip_prefix(r"\\?\")?;
|
||||||
let cut = rest.rfind("#{")?;
|
let cut = rest.rfind("#{")?;
|
||||||
Some(rest[..cut].replace('#', "\\"))
|
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() {
|
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();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
// Journal FIRST (union with any outstanding ids), so a crash between here and the disable
|
// 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() };
|
let session = unsafe { WTSGetActiveConsoleSessionId() };
|
||||||
if session == 0xFFFF_FFFF {
|
if session == 0xFFFF_FFFF {
|
||||||
// No interactive session yet (boot / fully logged out). Wait, but wake on stop/session.
|
// 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) {
|
if wait_any(&[stop, session_ev], 3000) == Some(0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -388,7 +388,10 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
|||||||
let child = match unsafe { spawn_host(session, &cmdline, &workdir, job_h) } {
|
let child = match unsafe { spawn_host(session, &cmdline, &workdir, job_h) } {
|
||||||
Ok(child) => child,
|
Ok(child) => child,
|
||||||
Err(e) => {
|
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) {
|
if wait_one(stop, 3000) {
|
||||||
break;
|
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`
|
// Child exited on its own — relaunch (with a small crash-loop backoff). The `child`
|
||||||
// drop closes its (already-exited) handles.
|
// 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::{
|
use windows::Win32::Devices::Display::{
|
||||||
DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes,
|
DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes,
|
||||||
QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
|
QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
|
||||||
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE,
|
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME,
|
||||||
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO,
|
DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO,
|
||||||
DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE, DISPLAYCONFIG_PATH_INFO,
|
DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE,
|
||||||
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME, QDC_ALL_PATHS,
|
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,
|
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,
|
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.header.id = p.targetInfo.id;
|
||||||
s.Anonymous.value = enable as u32; // bit 0 = enableAdvancedColor
|
s.Anonymous.value = enable as u32; // bit 0 = enableAdvancedColor
|
||||||
let rc = DisplayConfigSetDeviceInfo(&s.header);
|
let rc = DisplayConfigSetDeviceInfo(&s.header);
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
target_id,
|
target_id,
|
||||||
enable,
|
enable,
|
||||||
rc,
|
rc,
|
||||||
@@ -260,7 +270,7 @@ pub(crate) unsafe fn set_advanced_color(target_id: u32, enable: bool) -> bool {
|
|||||||
}
|
}
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target_id,
|
target_id,
|
||||||
"set_advanced_color: target not found in active paths"
|
"virtual-display advanced-color: target not in active paths"
|
||||||
);
|
);
|
||||||
false
|
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 +
|
/// 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
|
/// 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 /
|
/// 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");
|
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));
|
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)
|
Some(saved)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,5 +882,9 @@ pub(crate) unsafe fn restore_displays_ccd(saved: &SavedConfig) {
|
|||||||
Some(modes.as_slice()),
|
Some(modes.as_slice()),
|
||||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
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 {
|
fn status(&self) -> ksni::Status {
|
||||||
match &self.status {
|
match &self.status {
|
||||||
TrayStatus::Error(_) => ksni::Status::NeedsAttention,
|
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,
|
_ => ksni::Status::Active,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ pub struct Summary {
|
|||||||
/// host that doesn't send it deserializes as 0.
|
/// host that doesn't send it deserializes as 0.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub kept_displays: u32,
|
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)]
|
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
|
||||||
@@ -69,14 +74,16 @@ impl TrayStatus {
|
|||||||
TrayStatus::Starting => "punktfunk host — starting…".into(),
|
TrayStatus::Starting => "punktfunk host — starting…".into(),
|
||||||
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
|
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
|
||||||
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
|
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!(
|
(Some(sess), true) => format!(
|
||||||
"punktfunk host {} — streaming {}×{}@{}",
|
"punktfunk host {} — streaming {}×{}@{}",
|
||||||
s.version, sess.width, sess.height, sess.fps
|
s.version, sess.width, sess.height, sess.fps
|
||||||
),
|
),
|
||||||
(_, true) => format!("punktfunk host {} — streaming", s.version),
|
(_, true) => format!("punktfunk host {} — streaming", s.version),
|
||||||
// Idle, but surface a kept (lingering/pinned) display: it — and, under an exclusive
|
// Idle, but surface a kept (lingering/pinned) display: it — and, under an
|
||||||
// topology, your physical monitors — is being held. Release it from the console.
|
// exclusive topology, your physical monitors — is being held. Release it from
|
||||||
|
// the console.
|
||||||
_ if s.kept_displays > 0 => format!(
|
_ if s.kept_displays > 0 => format!(
|
||||||
"punktfunk host {} — idle · {} display{} kept",
|
"punktfunk host {} — idle · {} display{} kept",
|
||||||
s.version,
|
s.version,
|
||||||
@@ -84,9 +91,23 @@ impl TrayStatus {
|
|||||||
if s.kept_displays == 1 { "" } else { "s" }
|
if s.kept_displays == 1 { "" } else { "s" }
|
||||||
),
|
),
|
||||||
_ => format!("punktfunk host {} — idle", s.version),
|
_ => 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 {
|
pub fn is_streaming(&self) -> bool {
|
||||||
matches!(self, TrayStatus::Running(s) if s.video_streaming)
|
matches!(self, TrayStatus::Running(s) if s.video_streaming)
|
||||||
@@ -445,6 +466,7 @@ mod tests {
|
|||||||
pin_pending: false,
|
pin_pending: false,
|
||||||
pending_approvals: 0,
|
pending_approvals: 0,
|
||||||
kept_displays: 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]
|
#[test]
|
||||||
fn headline_shows_session_and_reason() {
|
fn headline_shows_session_and_reason() {
|
||||||
assert_eq!(
|
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
|
Docker/VLAN setups), pass `--no-mdns` (or set `PUNKTFUNK_MDNS=0`) and add the host in the client by
|
||||||
address instead.
|
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
|
## Environment
|
||||||
|
|
||||||
Most behaviour (compositor, video source, input backend, zero-copy) is set in
|
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.
|
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
|
## The host isn't found on the network
|
||||||
|
|
||||||
- Make sure the host is actually running (`systemctl --user status punktfunk-host`, or you see it
|
- 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),
|
The other packaging targets have their own READMEs: [`debian/`](debian/README.md) (apt),
|
||||||
[`arch/`](arch/README.md) (pacman binary repo + PKGBUILD + SteamOS sysext),
|
[`arch/`](arch/README.md) (pacman binary repo + PKGBUILD + SteamOS sysext),
|
||||||
[`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer +
|
[`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
|
## What's needed beyond base Fedora
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ MSG
|
|||||||
sudo firewall-cmd --reload
|
sudo firewall-cmd --reload
|
||||||
MSG
|
MSG
|
||||||
fi
|
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() {
|
post_upgrade() {
|
||||||
|
|||||||
@@ -210,6 +210,14 @@ if [ "$1" = "configure" ]; then
|
|||||||
echo " sudo firewall-cmd --permanent --add-service=punktfunk-native && sudo firewall-cmd --reload"
|
echo " sudo firewall-cmd --permanent --add-service=punktfunk-native && sudo firewall-cmd --reload"
|
||||||
echo " (use punktfunk-gamestream for the Moonlight-compat host)"
|
echo " (use punktfunk-gamestream for the Moonlight-compat host)"
|
||||||
fi
|
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
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
EOF
|
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 " sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload"
|
||||||
echo " (use punktfunk-native for the native-only host)"
|
echo " (use punktfunk-native for the native-only host)"
|
||||||
fi
|
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}
|
%if %{with web}
|
||||||
%post web
|
%post web
|
||||||
|
|||||||
@@ -285,6 +285,42 @@ Filename: "powershell.exe"; \
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
[Code]
|
[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
|
{ 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
|
PUNKTFUNK_HOST_CMD - only if it is unset or still one of the two canonical values, so a
|
||||||
hand-customized command line survives upgrades). }
|
hand-customized command line survives upgrades). }
|
||||||
|
|||||||
Reference in New Issue
Block a user