Merge pull request 'fix(host): a leftover Sunshine folder is not a conflict, and a crashed host gives the screen back' (#52) from worktree-conflict-detect-and-isolate-recovery into main
deb / build-publish (push) Canceled after 1s
deb / build-publish-client-arm64 (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
android / android (push) Canceled after 25s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
windows-host / package (push) Canceled after 1m11s
docker / builders-arm64cross (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
apple / swift (push) Canceled after 32s
windows-host / winget-source (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
arch / build-publish (push) Canceled after 37s
ci / rust (push) Canceled after 46s
ci / web (push) Canceled after 47s
ci / rust-arm64 (push) Canceled after 48s
ci / docs-site (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s

Reviewed-on: #52
This commit was merged in pull request #52.
This commit is contained in:
2026-08-04 21:00:56 +00:00
10 changed files with 509 additions and 78 deletions
@@ -1764,6 +1764,11 @@ impl VirtualDisplayManager {
if let Some(saved) = inner.group.ccd_saved.take() {
restore_displays_ccd(&saved);
}
// Drop the isolate's crash-recovery marker even when there was no snapshot to restore
// (a failed `isolate_displays_ccd` leaves `ccd_saved` None, and `restore_displays_ccd`
// — which clears it itself — then never runs). The group is gone either way, so no
// future host start owes this desk a force-EXTEND.
pf_win_display::win_display::isolate_journal::clear();
// EXPERIMENTAL `ddc_power_off` wake. OUTSIDE the `ccd_saved` gate, for the same reason
// `pnp_disabled` is above it: the panels were commanded dark BEFORE the isolate, and
// the isolate can return `None` (its `query_active_config` failed). Nested inside that
+201
View File
@@ -1215,6 +1215,186 @@ pub fn target_inventory() -> Vec<TargetInventory> {
out
}
/// Crash-recovery journal for the EXCLUSIVE isolate — the marker that lets a *fresh* host undo what
/// a *dead* one did.
///
/// [`isolate_displays_ccd`] deactivates the operator's physical displays and hands the pre-isolate
/// topology back to its caller, which restores it at teardown ([`restore_displays_ccd`]). That
/// snapshot lives in **process memory only**, so a host that crashes, is killed, or is stopped
/// mid-session never restores it. Windows does not restore it either — the isolated topology is
/// deliberately never saved to the CCD database, precisely so teardown can put the user's layout
/// back. The result was a field-reported dead end: the physical screen stays dark, no timeout ever
/// fires, and nothing in the product puts it back (the operator's only recourse was `DisplaySwitch`
/// or a reboot).
///
/// Same shape as [`monitor_devnode`](crate::monitor_devnode)'s PnP journal: write a marker while the
/// isolate is live, clear it on a clean restore, and re-light the desk at host startup if a marker
/// survived.
///
/// **Why the EXTEND preset rather than replaying the saved CCD blob.** That blob pins target ids
/// *including the virtual display's*, and the crashed host's monitors die with it (startup reaps the
/// orphans), so a replay would mostly fail `ERROR_BAD_CONFIGURATION` and land in the very
/// force-EXTEND backstop [`restore_displays_ccd`] already keeps for that case. EXTEND re-activates
/// every connected display from the OS's own database, needs no struct serialization, and stays
/// correct across a reboot — where saved target ids would be stale anyway.
pub mod isolate_journal {
use std::sync::Mutex;
/// What we last wrote, so the exclusive re-assert watchdog's repeat isolates don't rewrite the
/// file every couple of seconds. `None` = "no marker known to be on disk".
static LAST: Mutex<Option<Vec<u32>>> = Mutex::new(None);
fn path() -> std::path::PathBuf {
pf_paths::config_dir().join("display-isolate-active.json")
}
/// Record that `deactivated` physical target(s) are switched off for a live exclusive isolate.
/// Best-effort: a journal we cannot write costs crash recovery, not the session.
pub fn mark(deactivated: &[u32]) {
if deactivated.is_empty() {
return; // nothing was deactivated ⇒ nothing for a later host to put back
}
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
if last.as_deref() == Some(deactivated) {
return;
}
let p = path();
if let Some(dir) = p.parent() {
let _ = pf_paths::create_private_dir(dir);
}
match std::fs::write(
&p,
serde_json::to_vec_pretty(deactivated).unwrap_or_default(),
) {
Ok(()) => *last = Some(deactivated.to_vec()),
Err(e) => tracing::warn!(
error = %e,
"display isolate: could not write the crash-recovery journal — if this host dies \
mid-session the deactivated panels will stay dark"
),
}
}
/// The isolate is over (restored, or there was nothing to restore) — drop the marker.
/// Idempotent; safe to call when no marker exists.
pub fn clear() {
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
let _ = std::fs::remove_file(path());
*last = None;
}
/// Host-startup crash recovery: if a previous host exited with an exclusive isolate live, its
/// physical displays are still deactivated. Re-light them with the EXTEND preset.
///
/// Call once, early in `serve`, **before** any session touches the topology. Gated on the marker
/// rather than on "is anything active", so a legitimately headless host is never forced awake.
pub fn startup_recover() {
let Some(targets) = pending() else {
return;
};
tracing::warn!(
deactivated = ?targets,
"display isolate: a previous host exited with the operator's display(s) deactivated for \
an EXCLUSIVE session and never restored them — forcing the EXTEND preset so the desk is \
not left dark"
);
super::force_extend_topology();
clear();
}
/// The marker a previous host left behind, if any (its deactivated target ids) — the *decision*
/// half of [`startup_recover`], split out so the recovery rule is testable without driving a
/// real `SetDisplayConfig` against the machine running the test.
pub fn pending() -> Option<Vec<u32>> {
let bytes = std::fs::read(path()).ok()?;
Some(serde_json::from_slice(&bytes).unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
/// `PUNKTFUNK_CONFIG_DIR` (which `path()` resolves through) and the `LAST` cache are both
/// process-global, so these cases must not interleave.
static ENV: Mutex<()> = Mutex::new(());
/// Point the journal at a scratch dir for the duration of one case.
fn with_temp_dir(name: &str, f: impl FnOnce(&std::path::Path)) {
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("pf-isolate-journal-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch dir");
std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir);
clear(); // reset the LAST cache + any leftover marker from a previous run
f(&dir);
clear();
std::env::remove_var("PUNKTFUNK_CONFIG_DIR");
let _ = std::fs::remove_dir_all(&dir);
}
/// The crash path: a host marks what it switched off and dies. The next start must see the
/// marker (and which targets), which is what makes it force the desk back on.
#[test]
fn a_mark_survives_for_the_next_host_and_clear_retracts_it() {
with_temp_dir("roundtrip", |_| {
assert_eq!(pending(), None, "a clean box owes no recovery");
mark(&[101, 202]);
assert_eq!(
pending(),
Some(vec![101, 202]),
"a crashed host's marker must be readable by the next start"
);
clear();
assert_eq!(pending(), None, "a clean teardown retracts the marker");
});
}
/// An isolate that deactivated nothing (single-display box: the virtual output is already
/// the only head) owes the next start no force-EXTEND — marking there would re-arrange a
/// desk we never touched.
#[test]
fn deactivating_nothing_writes_no_marker() {
with_temp_dir("empty", |_| {
mark(&[]);
assert_eq!(pending(), None);
});
}
/// The re-assert watchdog re-isolates every couple of seconds while something fights it;
/// that must not mean a disk write per cycle.
#[test]
fn repeating_the_same_mark_does_not_rewrite_the_file() {
with_temp_dir("cached", |dir| {
let file = dir.join("display-isolate-active.json");
mark(&[7]);
// Overwrite behind the journal's back rather than comparing mtimes — a filesystem
// whose timestamp resolution is coarser than two back-to-back writes would let an
// mtime assertion pass without proving anything.
std::fs::write(&file, b"SENTINEL").unwrap();
mark(&[7]);
assert_eq!(
std::fs::read(&file).unwrap(),
b"SENTINEL",
"an unchanged mark must not rewrite the journal"
);
// A CHANGED set still lands — the group grew/shrank and recovery must follow it.
mark(&[7, 8]);
assert_eq!(pending(), Some(vec![7, 8]));
});
}
/// A corrupt/truncated journal must still trigger recovery: the FILE's existence is the
/// signal ("a host left displays off"), its contents are only diagnostics.
#[test]
fn an_unparseable_marker_still_asks_for_recovery() {
with_temp_dir("corrupt", |dir| {
std::fs::write(dir.join("display-isolate-active.json"), b"{ not json").unwrap();
assert_eq!(pending(), Some(Vec::new()));
});
}
}
}
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
@@ -1246,6 +1426,18 @@ pub fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfig> {
return Some(saved);
}
// Journal what we are about to switch off BEFORE the first apply, not after a verified one: the
// window this exists to cover includes dying mid-apply. `saved.0` is the ACTIVE path set
// (QDC_ONLY_ACTIVE_PATHS), so everything in it outside the keep set is exactly what teardown
// owes the operator back. See `isolate_journal`.
let doomed: Vec<u32> = saved
.0
.iter()
.map(|p| p.targetInfo.id)
.filter(|id| !keep_target_ids.contains(id))
.collect();
isolate_journal::mark(&doomed);
// Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical
// monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
@@ -1769,6 +1961,15 @@ static DARK_SINKS_FUTILE: std::sync::Mutex<Vec<(u32, String)>> = std::sync::Mute
/// removed), re-activating the displays we deactivated.
// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper.
pub fn restore_displays_ccd(saved: &SavedConfig) {
restore_displays_ccd_inner(saved);
// Clear the crash-recovery marker only AFTER the restore (and its dark-desk backstop) has run,
// never before: a host that dies part-way through the restore must still leave the marker
// behind so the next start re-lights the desk. `_inner` has several early returns, which is
// why this wraps rather than trailing the body.
isolate_journal::clear();
}
fn restore_displays_ccd_inner(saved: &SavedConfig) {
let (paths, modes) = saved;
if paths.is_empty() {
return;
+169 -46
View File
@@ -16,6 +16,19 @@
//! [`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.
//!
//! **Not every fingerprint is a conflict.** Only a host that is running, or that will start on its
//! own, can take the ports or load a second virtual-display driver. A leftover `Program Files`
//! folder from an uninstall, a binary on `PATH`, or a service registered but *disabled* clashes
//! with nothing — Sunshine's and Apollo's uninstallers both leave their config/log directories
//! behind, so treating mere presence as a conflict cries wolf on a machine whose other host is long
//! gone. [`Evidence::is_active`] draws that line and [`Detection::is_active`] lifts it to the
//! product; the warning surfaces (startup log, `/local/summary` → the web console's conflicts card,
//! the `detect-conflicts` exit code) report **only** active detections, while the full report still
//! lists the dormant ones as context for support. This matches the installer's own probe
//! (`punktfunk-host.iss`'s `StreamHostEnabled`: service start type <= 2), which was narrowed to
//! exactly this rule after a dormant Sunshine aborted a `winget install` in the field, and the tray,
//! which dropped its always-on warning over a merely-installed Sunshine in `3e782852`.
use std::sync::OnceLock;
@@ -73,17 +86,38 @@ impl Product {
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 },
/// An OS service / systemd unit for the product is registered. `autostart` is the load-bearing
/// bit: a service that comes up on its own (Windows start type boot/system/automatic; an enabled
/// systemd unit) *will* clash, whereas a disabled/manual one is inert until someone starts it by
/// hand — at which point the `Running` evidence catches it on the next scan.
Service { name: String, autostart: bool },
/// Installed on disk — a Program Files directory, a flatpak app id, or a binary on `PATH`.
/// Always dormant: files that nothing launches bind no ports.
Installed { at: String },
}
impl Evidence {
/// Does this observation mean a conflicting host will actually take the ports / load a second
/// virtual-display driver? See the module docs — this is the whole false-alarm fix.
pub fn is_active(&self) -> bool {
match self {
Evidence::Running { .. } => true,
Evidence::Service { autostart, .. } => *autostart,
Evidence::Installed { .. } => false,
}
}
fn render(&self) -> String {
match self {
Evidence::Running { process } => format!("running now ({process})"),
Evidence::Service { name } => format!("service {name}"),
Evidence::Service {
name,
autostart: true,
} => format!("service {name} (starts automatically)"),
Evidence::Service {
name,
autostart: false,
} => format!("service {name} (disabled/manual — dormant)"),
Evidence::Installed { at } => format!("installed at {at}"),
}
}
@@ -105,12 +139,24 @@ impl Detection {
.any(|e| matches!(e, Evidence::Running { .. }))
}
/// A compact one-line label for the tray/console summary, e.g. `Sunshine (running)`.
/// True when this host is running **or** will start on its own — i.e. the detection is worth
/// warning a user about. A product seen only as files on disk or a disabled service is dormant
/// and reports `false`; see the module docs.
pub fn is_active(&self) -> bool {
self.evidence.iter().any(Evidence::is_active)
}
/// A compact one-line label for the console summary, e.g. `Sunshine (running)`. The qualifier
/// names what was actually observed, so a card built from these labels can never claim a
/// dormant install is running.
pub fn label(&self) -> String {
let name = self.product.label();
if self.is_running() {
format!("{} (running)", self.product.label())
format!("{name} (running)")
} else if self.is_active() {
format!("{name} (starts automatically)")
} else {
self.product.label().to_string()
format!("{name} (installed, not running)")
}
}
}
@@ -225,28 +271,66 @@ 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()
/// True if any detection is active — the one gate the warning surfaces share (startup log, the
/// `detect-conflicts` exit code, the console card).
pub fn any_active(detections: &[Detection]) -> bool {
detections.iter().any(Detection::is_active)
}
/// 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()`).
/// Compact labels for the web-console summary (e.g. `["Sunshine (running)"]`).
///
/// **Active detections only.** A dormant leftover (an uninstalled Sunshine's `Program Files` folder,
/// a disabled service) is deliberately absent: this feeds the console's conflicts card, which exists
/// to explain why clients cannot reach a working-looking host, and files that nothing launches never
/// cause that. The full [`render_report`] still lists them for support.
pub fn summary_labels(detections: &[Detection]) -> Vec<String> {
detections
.iter()
.filter(|d| d.is_active())
.map(Detection::label)
.collect()
}
/// A full human-readable report, split by whether the finding can actually clash. Empty string when
/// nothing was detected at all (callers gate on `is_empty()`).
///
/// The dormant section is why this stays verbose where [`summary_labels`] is quiet: when a user asks
/// "why does Punktfunk think I have Apollo?", the answer is the exact leftover path, and the report
/// says in the same breath that it needs no action.
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 bullet = |d: &Detection| {
let ev = d
.evidence
.iter()
.map(Evidence::render)
.collect::<Vec<_>>()
.join("; ");
s.push_str(&format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label()));
format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label())
};
let (active, dormant): (Vec<_>, Vec<_>) = detections.iter().partition(|d| d.is_active());
let mut s = String::new();
if !active.is_empty() {
s.push_str("Detected another game-streaming host on this machine.\n");
s.push_str(UNSUPPORTED_BLURB);
s.push_str("\n\nDetected:\n");
for d in &active {
s.push_str(&bullet(d));
}
}
if !dormant.is_empty() {
if !active.is_empty() {
s.push('\n');
}
s.push_str(
"Also present but DORMANT — not running and not set to start on its own, so it clashes \
with nothing and needs no action (typically leftovers from an uninstall):\n",
);
for d in &dormant {
s.push_str(&bullet(d));
}
}
s
}
@@ -275,15 +359,19 @@ mod tests {
},
Evidence::Service {
name: "SunshineService".into(),
autostart: true,
},
],
);
assert!(d.is_running());
assert!(d.is_active());
assert_eq!(d.label(), "Sunshine (running)");
}
/// The field case this split exists for: Apollo uninstalled, its `Program Files` folder left
/// behind. Nothing launches it, so it is NOT a conflict and must never reach the console card.
#[test]
fn installed_only_is_not_running() {
fn a_leftover_install_dir_is_dormant_and_never_surfaces() {
let d = det(
Product::Apollo,
vec![Evidence::Installed {
@@ -291,42 +379,77 @@ mod tests {
}],
);
assert!(!d.is_running());
assert_eq!(d.label(), "Apollo");
assert!(!d.is_active(), "files on disk cannot bind a port");
assert_eq!(d.label(), "Apollo (installed, not running)");
assert!(summary_labels(std::slice::from_ref(&d)).is_empty());
assert!(!any_active(&[d]));
}
/// A registered-but-DISABLED service is the other half of the same false alarm: `service_exists`
/// used to count it, which disagreed with the installer's `Start <= 2` probe.
#[test]
fn a_disabled_service_is_dormant_but_an_autostart_one_is_not() {
let disabled = det(
Product::Sunshine,
vec![Evidence::Service {
name: "SunshineService".into(),
autostart: false,
}],
);
assert!(!disabled.is_active());
assert!(summary_labels(&[disabled]).is_empty());
let auto = det(
Product::Sunshine,
vec![Evidence::Service {
name: "SunshineService".into(),
autostart: true,
}],
);
assert!(auto.is_active());
assert!(!auto.is_running(), "registered to start != started");
assert_eq!(auto.label(), "Sunshine (starts automatically)");
assert_eq!(
summary_labels(&[auto]),
vec!["Sunshine (starts automatically)".to_string()]
);
}
#[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(),
}],
),
]);
fn report_separates_active_from_dormant_and_keeps_the_blurb() {
let active = det(
Product::Sunshine,
vec![Evidence::Running {
process: "sunshine".into(),
}],
);
let dormant = det(
Product::Apollo,
vec![Evidence::Installed {
at: "/usr/bin/apollo".into(),
}],
);
let report = render_report(&[active.clone(), dormant.clone()]);
assert!(report.contains("UNSUPPORTED"));
// The bullets name the PRODUCT and let the evidence speak — `Detection::label`'s qualifier
// would only restate what follows the dash ("Sunshine (running) — running now (sunshine)").
// The qualifier is for `summary_labels`, which has no evidence text beside it.
assert!(report.contains("Sunshine \u{2014} running now (sunshine)"));
assert!(report.contains("DORMANT"));
assert!(report.contains("Apollo \u{2014} installed at /usr/bin/apollo"));
// Only the live one is offered to the console card.
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()]
summary_labels(&[active, dormant.clone()]),
vec!["Sunshine (running)".to_string()]
);
// A dormant-only machine gets the explanatory listing WITHOUT the "unsupported" alarm — the
// whole point is that this needs no action.
let dormant_only = render_report(&[dormant]);
assert!(dormant_only.contains("DORMANT"));
assert!(
!dormant_only.contains("UNSUPPORTED"),
"a leftover folder must not read as an unsupported dual-host setup:\n{dormant_only}"
);
}
+48 -1
View File
@@ -50,7 +50,11 @@ pub fn static_evidence(known: &Known) -> Vec<Evidence> {
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 });
let autostart = unit_enabled(&file, home.as_deref());
ev.push(Evidence::Service {
name: file,
autostart,
});
}
}
@@ -78,6 +82,49 @@ pub fn static_evidence(known: &Known) -> Vec<Evidence> {
ev
}
/// Is `unit` (a `<name>.service` filename) **enabled** — i.e. will systemd start it on its own?
///
/// `systemctl enable` works by symlinking the unit into a target's `.wants`/`.requires` directory,
/// so the presence of that link is the enablement fact — readable without spawning `systemctl`
/// (this module is deliberately subprocess-free, and the host often runs where `systemctl` output
/// would need a bus connection anyway). A unit file that exists but is linked from no target is
/// installed-but-inert: nothing starts it at boot, so it clashes with nothing.
///
/// Scans the `.wants`/`.requires` subdirectories of the drop-in roots systemd actually reads, rather
/// than hardcoding `multi-user.target` — a unit pulled in by `graphical.target`, a user
/// `default.target`, or any other target is just as enabled.
fn unit_enabled(unit: &str, home: Option<&std::ffi::OsStr>) -> bool {
let mut roots: 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 {
roots.push(format!("{}/.config/systemd/user", h.to_string_lossy()));
}
for root in roots {
let Ok(entries) = std::fs::read_dir(&root) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !(name.ends_with(".wants") || name.ends_with(".requires")) {
continue;
}
// `symlink_metadata` so a DANGLING link still counts: a link into a target's .wants is
// what "enabled" means, and a broken one still says the operator enabled it.
if std::fs::symlink_metadata(entry.path().join(unit)).is_ok() {
return true;
}
}
}
false
}
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).
+32 -10
View File
@@ -7,7 +7,7 @@ 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::{ServiceAccess, ServiceStartType};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
/// Lowercased executable basenames (without `.exe`) of every running process, via a Toolhelp
@@ -49,9 +49,10 @@ pub fn running_processes() -> Vec<String> {
pub fn static_evidence(known: &Known) -> Vec<Evidence> {
let mut ev = Vec::new();
for svc in known.win_services {
if service_exists(svc) {
if let Some(autostart) = service_start_type(svc) {
ev.push(Evidence::Service {
name: (*svc).to_string(),
autostart,
});
}
}
@@ -63,14 +64,35 @@ pub fn static_evidence(known: &Known) -> Vec<Evidence> {
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()
/// `Some(autostart)` if a service by this name is registered with the SCM (running or stopped),
/// `None` if it does not exist. Opening it fails cleanly when it doesn't exist.
///
/// `autostart` mirrors the installer's `StreamHostEnabled` (start type <= 2): only boot/system/auto
/// come up on their own, and only a host that comes up can take the GameStream ports. A disabled or
/// manual service is dormant — see the module docs on `super`. When the start type cannot be read
/// (no `QUERY_CONFIG` right) we report the service as dormant rather than guessing it autostarts:
/// the false-alarm this whole split exists to kill is worse than a missed warning, and a host that
/// is genuinely up is caught by the process scan regardless of what its service config says.
fn service_start_type(name: &str) -> Option<bool> {
let mgr = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
let svc = mgr
.open_service(
name,
ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
)
// Fall back to a status-only handle so a service we may not configure still registers as
// present (dormant) instead of vanishing from the report entirely.
.or_else(|_| mgr.open_service(name, ServiceAccess::QUERY_STATUS))
.ok()?;
let autostart = svc.query_config().is_ok_and(|c| {
matches!(
c.start_type,
ServiceStartType::AutoStart
| ServiceStartType::BootStart
| ServiceStartType::SystemStart
)
});
Some(autostart)
}
/// The install directory under any of the Program Files roots, if it exists.
+18 -7
View File
@@ -334,15 +334,26 @@ pub fn serve(
"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.
// scan once (cached for `/local/summary` → the web console) and warn loudly if one can actually
// clash. A dormant leftover (an uninstalled Sunshine's Program Files folder, a disabled service)
// is logged at INFO instead — it belongs in a support log, not in a warning that reads like a
// fault on every boot.
let conflicts = crate::detect::init();
if !conflicts.is_empty() {
tracing::warn!(
target: "punktfunk::detect",
count = conflicts.len(),
"{}",
crate::detect::render_report(conflicts)
);
let report = crate::detect::render_report(conflicts);
if crate::detect::any_active(conflicts) {
tracing::warn!(
target: "punktfunk::detect",
count = conflicts.len(),
"{report}"
);
} else {
tracing::info!(
target: "punktfunk::detect",
count = conflicts.len(),
"{report}"
);
}
}
if gamestream {
tracing::warn!(
+21 -5
View File
@@ -104,10 +104,14 @@ mod tray;
mod store;
mod stream_marker;
mod update;
// `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior
// run; it lives in the `pf-win-display` leaf crate (plan §W6).
// The two startup crash-recovery legs (below), both in the `pf-win-display` leaf crate (plan §W6):
// `monitor_devnode::startup_recover()` re-enables PnP monitor devnodes disabled by a prior run, and
// `isolate_journal::startup_recover()` re-lights displays a prior run deactivated for an EXCLUSIVE
// session and never restored.
#[cfg(target_os = "windows")]
use pf_win_display::monitor_devnode;
#[cfg(target_os = "windows")]
use pf_win_display::win_display::isolate_journal;
// Virtual-display orchestration lives in the `pf-vdisplay` subsystem crate (plan §W6); this shim
// keeps every existing `crate::vdisplay::*` path valid (serve/mgmt/native/capture consume the trait,
// registry, and manager through it). The DDC panel control + the KWin zkde protocol moved with it.
@@ -379,6 +383,12 @@ fn real_main() -> Result<()> {
// restored (crash/kill/power loss) — before any new session touches the topology.
#[cfg(target_os = "windows")]
monitor_devnode::startup_recover();
// The same recovery for the DEFAULT Exclusive path: a previous host that died holding a
// CCD isolate left the operator's panels deactivated with nothing to put them back (the
// restore snapshot was process memory). Runs AFTER the devnode leg so re-enabled
// monitors are present again and the EXTEND preset can actually light them.
#[cfg(target_os = "windows")]
isolate_journal::startup_recover();
gamestream::serve(mgmt_opts, native, gamestream)
}
// Report other Moonlight-compatible hosts (Sunshine/Apollo/…) installed or running on this
@@ -388,11 +398,17 @@ fn real_main() -> Result<()> {
let found = detect::scan();
if found.is_empty() {
println!("No conflicting game-streaming host detected.");
Ok(())
} else {
print!("{}", detect::render_report(&found));
return Ok(());
}
print!("{}", detect::render_report(&found));
// Exit 1 ONLY for a host that runs or will start on its own. The installers and support
// scripts gate on this code, and a dormant leftover used to abort them — a `winget
// install` failed in the field on a box whose Sunshine was merely present (see the
// module docs + `punktfunk-host.iss`). Dormant findings print, then exit 0.
if detect::any_active(&found) {
std::process::exit(1);
}
Ok(())
}
// Install and run host plugins: `plugins add playnite`, `plugins enable`, … Package ops are
// forwarded to the bun runner; enable/disable/status drive the systemd unit (Linux) or the
+2 -2
View File
@@ -134,8 +134,8 @@
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.",
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.",
"host_conflicts_title": "Auf diesem Rechner läuft ein weiterer Game-Streaming-Server",
"host_conflicts_help": "Er belegt dieselben Ports wie Punktfunk — es antwortet also der Server, der zuerst gestartet ist. Das ist meist der Grund, warum sich ein scheinbar funktionierender Host nicht verbinden lässt. Beende oder deinstalliere den anderen Server und starte Punktfunk neu.",
"host_conflicts_title": "Auf diesem Rechner ist ein weiterer Game-Streaming-Server aktiv",
"host_conflicts_help": "Er läuft oder startet automatisch mit und belegt dieselben Ports wie Punktfunk — es antwortet also der Server, der zuerst gestartet ist. Das ist meist der Grund, warum sich ein scheinbar funktionierender Host nicht verbinden lässt. Beende und deaktiviere den anderen Server und starte Punktfunk neu. Ein Server, der nur installiert ist, stört nicht und wird hier nicht aufgeführt.",
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
"display_config_title": "Konfiguration",
"display_preset": "Voreinstellung",
+2 -2
View File
@@ -134,8 +134,8 @@
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.",
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.",
"host_conflicts_title": "Another game-streaming server is running on this machine",
"host_conflicts_help": "It listens on the same ports as punktfunk, so whichever one started first answers your clients which is usually why a working-looking host cannot be connected to. Stop or uninstall the other server, then restart punktfunk.",
"host_conflicts_title": "Another game-streaming server is active on this machine",
"host_conflicts_help": "It is running, or set to start on its own, and listens on the same ports as Punktfunk so whichever one started first answers your clients, which is usually why a working-looking host cannot be connected to. Stop and disable the other server, then restart Punktfunk. A server that is only left installed does not clash and is not listed here.",
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
"display_config_title": "Configuration",
"display_preset": "Preset",
+11 -5
View File
@@ -7,11 +7,17 @@ import { m } from "@/paraglide/messages";
/**
* "Something else is already listening on these ports."
*
* The host detects other Moonlight-compatible servers (Sunshine, Apollo, ) running on the same
* machine at startup and reports them in `GET /local/summary` as `conflicts`. Nothing surfaced it,
* even though it is the single most common reason a punktfunk host looks installed and working but
* no client can reach it two servers fighting over the same ports, with whichever won the bind
* answering the client.
* The host detects other Moonlight-compatible servers (Sunshine, Apollo, ) on the same machine at
* startup and reports them in `GET /local/summary` as `conflicts`. Nothing surfaced it, even though
* it is the single most common reason a Punktfunk host looks installed and working but no client can
* reach it two servers fighting over the same ports, with whichever won the bind answering the
* client.
*
* `conflicts` carries only servers that are running or set to start on their own; the host filters
* dormant leftovers out (see `detect.rs`), because an uninstalled Sunshine's `Program Files` folder
* clashes with nothing and this card used to shout about it on every load. Each entry names what was
* observed `Sunshine (running)`, `Apollo (starts automatically)` so the heading never has to
* guess, which it previously did by hardcoding "is running".
*
* Renders nothing at all when there is no conflict, so a healthy host sees no extra chrome.
*/