Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cd35e15ca | ||
|
|
700275fa0d | ||
|
|
6352ff629d |
@@ -148,14 +148,28 @@ mod imp {
|
||||
/// place used to be the design ("reverts at process exit") — but the host is a 24/7 service,
|
||||
/// so after one stream it competed at HIGH class with a 1 ms global timer against whatever
|
||||
/// the user played locally, forever.
|
||||
///
|
||||
/// 🛑 **Nothing in here may log, or touch anything that logs.** This runs from
|
||||
/// [`HotThreadGuard`]'s `Drop`, which is a **TLS destructor** — and by then this thread's
|
||||
/// *other* thread-locals may already be gone, including the ones `tracing_subscriber`'s
|
||||
/// registry keeps (it is `sharded-slab`-backed, and the slab's per-thread registration is a
|
||||
/// `thread_local!` read with `LocalKey::with`). Emitting an event here panicked with "cannot
|
||||
/// access a Thread Local Storage value during or after destruction", and **a panic that
|
||||
/// escapes a TLS destructor is fatal in Rust** — `fatal runtime error: thread local panicked
|
||||
/// on drop, aborting`. So one `info!` line killed the whole host on session teardown and the
|
||||
/// SCM restarted it ~6 s later, which read in the field as a mystery reconnect (on glass,
|
||||
/// .173: four aborts, every one of them a session teardown).
|
||||
///
|
||||
/// The revert itself is only FFI and stays here, inside the refcount lock, so it remains
|
||||
/// atomic against a session starting concurrently. The counterpart "applied" line in
|
||||
/// [`tune_process`] runs on a live thread and is kept — that one is safe.
|
||||
fn untune_process() {
|
||||
// SAFETY: same FFI surface as `tune_process` — plain-integer arguments, constant
|
||||
// pseudo-handle, no pointers or buffers.
|
||||
// pseudo-handle, no pointers or buffers. Sound in a TLS destructor: no Rust TLS is read.
|
||||
unsafe {
|
||||
timeEndPeriod(1); // pairs the timeBeginPeriod(1)
|
||||
DwmEnableMMCSS(0);
|
||||
SetPriorityClass(GetCurrentProcess(), NORMAL_PRIORITY_CLASS);
|
||||
tracing::info!("windows session tuning reverted (timer, DWM MMCSS, NORMAL priority)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,8 +179,9 @@ mod imp {
|
||||
|
||||
impl Drop for HotThreadGuard {
|
||||
fn drop(&mut self) {
|
||||
// A poisoned lock skips the revert (best-effort, like every call here) instead of
|
||||
// panicking inside a TLS destructor.
|
||||
// ⚠ TLS DESTRUCTOR. Everything reached from here must be panic-free and must not log —
|
||||
// see [`untune_process`] for what a single `info!` here cost. A poisoned lock skips the
|
||||
// revert (best-effort, like every call here) rather than panicking.
|
||||
if let Ok(mut n) = HOT_THREADS.lock() {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
|
||||
@@ -574,6 +574,10 @@ fn watch(
|
||||
"the launch command exited immediately (a launcher handing off) and this \
|
||||
title has no detect signals — stopping game tracking for it"
|
||||
);
|
||||
// Nothing will ever observe this game again, so say that rather than leave the
|
||||
// console on "launching" forever — the same honest answer `open` reaches when it
|
||||
// starts no watcher at all.
|
||||
shared.set_state(GameState::Untracked);
|
||||
return;
|
||||
}
|
||||
tracing::debug!(
|
||||
@@ -618,6 +622,7 @@ fn watch(
|
||||
LeaseKind::Matched
|
||||
};
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
shared.set_state(GameState::Untracked);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -643,6 +648,7 @@ fn watch(
|
||||
LeaseKind::Matched
|
||||
};
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
shared.set_state(GameState::Untracked);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -654,17 +660,25 @@ fn watch(
|
||||
// detect signals is still fully tracked.
|
||||
//
|
||||
// But a launcher that is about to hand off and exit looks *exactly* like the game for its
|
||||
// first few seconds. When the store gave us signals to recognize the real game by, wait out
|
||||
// the shim window before believing this child is it — otherwise the lease leaves this phase
|
||||
// on its very first poll, the reclassification above never gets to run, and the hand-off
|
||||
// that follows is read as the game exiting. On Linux that ended a session ~7 s after
|
||||
// launching any Steam title, before the game had even started (on glass, .41).
|
||||
// first few seconds, so wait out the shim window before believing this child is it —
|
||||
// otherwise the lease leaves this phase on its very first poll, the reclassification above
|
||||
// never gets to run, and the hand-off that follows is read as the game exiting. On Linux
|
||||
// that ended a session ~7 s after launching any Steam title, before the game had even
|
||||
// started (on glass, .41).
|
||||
//
|
||||
// With no signals the child is all we have, so it still counts immediately: a custom command
|
||||
// is tracked exactly as before.
|
||||
// ⚠ This used to be skipped whenever the title had **no** detect signals, on the reasoning
|
||||
// that the child was then all we had — which quietly made the no-signals case the one shape
|
||||
// the shim window could not protect. It is the shape that needs it most: a hint-less title
|
||||
// is exactly the one whose launch is a bare protocol hand-off, and `spec.is_empty()` is
|
||||
// *fewer* reasons to trust the child, not more. On Windows every launch recipe is a
|
||||
// hand-off by construction (`explorer.exe "playnite://…"`, `Steam.exe "steam://…"`), so
|
||||
// carrying its pid (0.30) made a hint-less title report `running` on its first poll and
|
||||
// `exited` a second later, when the forwarder quit — ending the session and dropping the
|
||||
// stream while the game was still starting. Both callers of the pid path already documented
|
||||
// this window as their protection; now they have it.
|
||||
let child_alive = matches!(kind, LeaseKind::Child)
|
||||
&& (child.is_some() || spawned.is_some())
|
||||
&& (shared.spec.is_empty() || spawned_at.elapsed() >= SHIM_WINDOW);
|
||||
&& spawned_at.elapsed() >= SHIM_WINDOW;
|
||||
let live = scanner.find(&shared.spec, shared.launch_stamp);
|
||||
if !live.is_empty() || child_alive {
|
||||
known = live.clone();
|
||||
@@ -1744,10 +1758,16 @@ mod tests {
|
||||
/// The same launch, driven to its exit: the pid dying is the game exiting, and that fires the
|
||||
/// action that ends the session — which is precisely what never happened in the field report.
|
||||
///
|
||||
/// Ignored by default: it waits out [`EXIT_CONFIRM`] after a real process ends, ~10 s.
|
||||
/// ⚠ The process must outlive [`SHIM_WINDOW`] for that reading to be the right one. It used to
|
||||
/// be a 4-second `sleep`, which is *inside* the window — the test passed only because a lease
|
||||
/// with no detect signals skipped the window entirely, which is the bug the sibling test below
|
||||
/// pins. Keep this fixture longer than the window: a launch that quits sooner is a hand-off, and
|
||||
/// treating it as a game exit is what dropped the stream a second after every Windows launch.
|
||||
///
|
||||
/// Ignored by default: it outlives the shim window and then waits out [`EXIT_CONFIRM`], ~12 s.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "drives a real process for ~10s (exit confirmation)"]
|
||||
#[ignore = "drives a real process for ~12s (shim window + exit confirmation)"]
|
||||
fn a_pid_only_launch_reports_its_exit() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
@@ -1755,7 +1775,7 @@ mod tests {
|
||||
// `/proc/<pid>` entry with an unchanged start time — so the scan would call it alive
|
||||
// forever and the exit under test could never be observed.
|
||||
let mut child = std::process::Command::new("sleep")
|
||||
.arg("4")
|
||||
.arg("8")
|
||||
.spawn()
|
||||
.expect("spawn the fake game");
|
||||
let pid = child.id();
|
||||
@@ -1778,7 +1798,7 @@ mod tests {
|
||||
);
|
||||
let shared = lease.shared();
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline && shared.state() != GameState::Exited {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
@@ -1790,6 +1810,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 🛑 The 2026-08-18 field report, in one test: a Windows launch is a **protocol hand-off**, and
|
||||
/// a hand-off must never be mistaken for the game exiting.
|
||||
///
|
||||
/// The shape is `explorer.exe "playnite://playnite/start/<id>"` — the host spawns a forwarder,
|
||||
/// gets its pid, and the forwarder quits about a second later having handed the launch to
|
||||
/// Playnite. The title carries no detect hint (the Playnite plugin only sends `install_dir` when
|
||||
/// Playnite knows one), so the lease has the pid and nothing else.
|
||||
///
|
||||
/// What shipped in 0.30 did this: the empty spec skipped [`SHIM_WINDOW`], so the lease called
|
||||
/// the forwarder "the game running" on its first poll, and a second later called the
|
||||
/// forwarder's exit "the game exited" — closing the connection with `APP_EXITED`. The player
|
||||
/// saw the game start on the host and the stream drop, with the console reporting no running
|
||||
/// game. Two things have to hold for that not to happen, and both are asserted here.
|
||||
///
|
||||
/// Ignored by default: it must outlive [`SHIM_WINDOW`] and [`EXIT_CONFIRM`] to prove the
|
||||
/// session is not ended *later* either.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "drives a real process for ~10s (shim window + exit confirmation)"]
|
||||
fn a_pid_only_handoff_with_no_signals_never_ends_the_session() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
// Reaped on its own thread — see the sibling test: a zombie keeps its `/proc` entry and
|
||||
// would read as alive forever.
|
||||
let mut child = std::process::Command::new("sleep")
|
||||
.arg("1")
|
||||
.spawn()
|
||||
.expect("spawn the fake forwarder");
|
||||
let pid = child.id();
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
|
||||
static HANDOFF_EXITS: AtomicUsize = AtomicUsize::new(0);
|
||||
HANDOFF_EXITS.store(0, Ordering::SeqCst);
|
||||
let lease = open(
|
||||
LeaseRequest {
|
||||
spawned: Some(pid),
|
||||
spec: DetectSpec::default(),
|
||||
launch_stamp: launch_clock(),
|
||||
..req("playnite:handoff", DetectSpec::default(), false)
|
||||
},
|
||||
Box::new(|| {
|
||||
HANDOFF_EXITS.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
let shared = lease.shared();
|
||||
|
||||
std::thread::sleep(SHIM_WINDOW + EXIT_CONFIRM + Duration::from_secs(2));
|
||||
assert_eq!(
|
||||
HANDOFF_EXITS.load(Ordering::SeqCst),
|
||||
0,
|
||||
"a launch command handing off must not end the session — this is the field report"
|
||||
);
|
||||
// ...and the console must not be told the game is up either. `Untracked` is the honest
|
||||
// answer: nothing is watching this title, so nothing will ever report it starting or
|
||||
// stopping. Sitting at `Launching` (or claiming `Running`) are the two lies 0.30 set out
|
||||
// to remove, and giving up on tracking must not quietly reinstate one of them.
|
||||
assert_eq!(
|
||||
shared.state(),
|
||||
GameState::Untracked,
|
||||
"nothing is watching this title any more, and the row has to say so"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
|
||||
/// notices when it exits, and reports that exit exactly once.
|
||||
///
|
||||
|
||||
@@ -332,7 +332,8 @@ fn run(
|
||||
#[allow(unused_mut)]
|
||||
let mut spawned_now = false;
|
||||
// Windows hands back a pid rather than a child; kept for the lease (see the native plane
|
||||
// and `gamelease::LeaseRequest::spawned`). `None` elsewhere and when nothing was spawned.
|
||||
// and `gamelease::LeaseRequest::spawned`). `None` elsewhere, when nothing was spawned, and
|
||||
// when what was spawned only forwards the launch (`library::WinRecipe::owns_game`).
|
||||
#[allow(unused_mut)]
|
||||
let mut spawned_pid: Option<u32> = None;
|
||||
// Close this client's previous game first, when the operator asked for that — the compat
|
||||
@@ -365,8 +366,8 @@ fn run(
|
||||
(None, None) => Ok(None),
|
||||
};
|
||||
match launched {
|
||||
Ok(pid) => {
|
||||
spawned_pid = pid;
|
||||
Ok(l) => {
|
||||
spawned_pid = l.and_then(|l| l.tracked_pid());
|
||||
spawned_now = true;
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -176,6 +176,70 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved Windows launch: the command line to spawn, the directory to spawn it in, and whether
|
||||
/// the process that line starts **is** the game.
|
||||
///
|
||||
/// [`Self::owns_game`] is the whole reason this is a struct and not a pair. Almost every Windows
|
||||
/// recipe is a protocol hand-off — `explorer.exe "playnite://…"`, `Steam.exe "steam://…"` — that
|
||||
/// forwards the request to whichever launcher owns the title and then exits. Its pid is a
|
||||
/// forwarder's, so that pid's lifetime says nothing about the game's, in either direction:
|
||||
///
|
||||
/// * the launcher was already running, so the forwarder quits a second later — read as a `Child`
|
||||
/// lease, that is the game "exiting" while it is still loading;
|
||||
/// * the launcher was *not* running, so the process the host started becomes the launcher itself
|
||||
/// and outlives every game the player then quits — a lease that can never report an exit.
|
||||
///
|
||||
/// Only a line that starts the game (or the operator's own command) directly earns its pid a place
|
||||
/// in [`crate::gamelease::LeaseRequest::spawned`]; a hand-off pid is dropped, and the lease falls
|
||||
/// back to the title's detect signals, exactly as it did before the pid was carried at all.
|
||||
#[cfg(windows)]
|
||||
pub struct WinRecipe {
|
||||
/// The full command line to hand to `CreateProcessAsUserW`.
|
||||
pub cmdline: String,
|
||||
/// The working directory to start it in, when the recipe needs a specific one.
|
||||
pub workdir: Option<std::path::PathBuf>,
|
||||
/// See the type docs: `false` for a protocol/launcher hand-off.
|
||||
pub owns_game: bool,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl WinRecipe {
|
||||
/// A line that forwards the launch to whoever owns the title and then exits.
|
||||
fn handoff(cmdline: String) -> Self {
|
||||
Self {
|
||||
cmdline,
|
||||
workdir: None,
|
||||
owns_game: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A line that starts the game — or the operator's own command — as its own process.
|
||||
fn game(cmdline: String, workdir: Option<std::path::PathBuf>) -> Self {
|
||||
Self {
|
||||
cmdline,
|
||||
workdir,
|
||||
owns_game: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a Windows launch started, as the lease needs to hear it — see [`WinRecipe::owns_game`].
|
||||
#[cfg(windows)]
|
||||
pub struct WindowsLaunch {
|
||||
/// The pid `CreateProcessAsUserW` handed back.
|
||||
pub pid: u32,
|
||||
/// Whether that pid is the game's rather than a forwarder's.
|
||||
pub owns_game: bool,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl WindowsLaunch {
|
||||
/// The pid to carry on the lease: `None` when all the host started was a hand-off.
|
||||
pub fn tracked_pid(&self) -> Option<u32> {
|
||||
self.owns_game.then_some(self.pid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: launch a store-qualified library id into the **interactive user session** — the Windows
|
||||
/// analogue of the Linux gamescope-nested [`resolve_launch`]. The id is resolved against the host's
|
||||
/// OWN library (the client never sends a command), mapped to a concrete process by
|
||||
@@ -184,12 +248,13 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
|
||||
/// Wired into the data plane *after* capture is live, so the title renders onto the already-captured
|
||||
/// desktop and grabs foreground.
|
||||
///
|
||||
/// Returns the **pid of the process it started**, which is what the caller hands to
|
||||
/// [`crate::gamelease::LeaseRequest::spawned`]. It used to be logged and discarded, and that was the
|
||||
/// whole of Windows' disadvantage against Linux here: with no `Child` to hold and no pid kept, a
|
||||
/// title whose provider supplied no detect hint left the lease nothing to watch or signal.
|
||||
/// Returns the process it started and whether that process is the game ([`WindowsLaunch`]) — the
|
||||
/// pid is what the caller hands to [`crate::gamelease::LeaseRequest::spawned`], but only when it
|
||||
/// belongs to the game. It used to be logged and discarded, and that was the whole of Windows'
|
||||
/// disadvantage against Linux here: with no `Child` to hold and no pid kept, a title whose provider
|
||||
/// supplied no detect hint left the lease nothing to watch or signal.
|
||||
#[cfg(windows)]
|
||||
pub fn launch_title(id: &str) -> Result<u32> {
|
||||
pub fn launch_title(id: &str) -> Result<WindowsLaunch> {
|
||||
let entry = all_games()
|
||||
.into_iter()
|
||||
.find(|g| g.id == id)
|
||||
@@ -199,8 +264,10 @@ pub fn launch_title(id: &str) -> Result<u32> {
|
||||
// A `plugin` entry's recipe comes from the plugin that owns it, and arrives in the same
|
||||
// (command line, working dir) shape this path already spawns. `windows_launch_for` has no arm
|
||||
// for the kind, so a failed ask falls through to the "no recipe" error below.
|
||||
let (cmdline, workdir) = plugin_recipe(&entry)
|
||||
.map(|l| (l.command, l.cwd))
|
||||
// A plugin publishes a concrete `(command line, working dir)` for its own title, the same shape
|
||||
// the operator-typed `command` kind produces — so it is spawned, and tracked, on the same terms.
|
||||
let recipe = plugin_recipe(&entry)
|
||||
.map(|l| WinRecipe::game(l.command, l.cwd))
|
||||
.or_else(|| windows_launch_for(&spec))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
@@ -208,10 +275,21 @@ pub fn launch_title(id: &str) -> Result<u32> {
|
||||
spec.kind
|
||||
)
|
||||
})?;
|
||||
let WinRecipe {
|
||||
cmdline,
|
||||
workdir,
|
||||
owns_game,
|
||||
} = recipe;
|
||||
let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref())
|
||||
.with_context(|| format!("launch '{id}' in the interactive session"))?;
|
||||
tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session");
|
||||
Ok(pid)
|
||||
tracing::info!(
|
||||
launch_id = id,
|
||||
%cmdline,
|
||||
pid,
|
||||
owns_game,
|
||||
"launched library title in the interactive session"
|
||||
);
|
||||
Ok(WindowsLaunch { pid, owns_game })
|
||||
}
|
||||
|
||||
/// Windows: map a resolved [`LaunchSpec`] to a `(command line, working dir)` to spawn into the
|
||||
@@ -223,7 +301,7 @@ pub fn launch_title(id: &str) -> Result<u32> {
|
||||
/// The `plugin` kind is deliberately absent: its answer comes from another process, so it is
|
||||
/// resolved by [`plugin_recipe`] before this is reached.
|
||||
#[cfg(windows)]
|
||||
fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::PathBuf>)> {
|
||||
fn windows_launch_for(spec: &LaunchSpec) -> Option<WinRecipe> {
|
||||
match spec.kind.as_str() {
|
||||
"steam_appid" => {
|
||||
if !valid_steam_appid(&spec.value) {
|
||||
@@ -237,7 +315,9 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
|
||||
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
|
||||
None => format!("explorer.exe \"{uri}\""),
|
||||
};
|
||||
Some((cmdline, None))
|
||||
// Either line is a forwarder: `Steam.exe <uri>` against a running client posts the URI
|
||||
// and exits, and against a cold one it *becomes* the client. Neither is the game.
|
||||
Some(WinRecipe::handoff(cmdline))
|
||||
}
|
||||
// A launcher entry (D4): open the Steam client's own UI. Same Steam.exe-then-explorer ladder
|
||||
// as `steam_appid`, and the URI is one of exactly two host-owned literals — nothing from the
|
||||
@@ -252,23 +332,22 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
|
||||
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
|
||||
None => format!("explorer.exe \"{uri}\""),
|
||||
};
|
||||
Some((cmdline, None))
|
||||
Some(WinRecipe::handoff(cmdline))
|
||||
}
|
||||
// Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a
|
||||
// concrete EXE that resolves the registered protocol handler as the user; the URI is a single
|
||||
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
|
||||
"epic" => epic_launch_uri(&spec.value).map(|uri| (format!("explorer.exe \"{uri}\""), None)),
|
||||
"epic" => epic_launch_uri(&spec.value)
|
||||
.map(|uri| WinRecipe::handoff(format!("explorer.exe \"{uri}\""))),
|
||||
// GOG: spawn the resolved game exe directly (host-derived from goggame-<id>.info), no Galaxy.
|
||||
"gog" => gog_spawn(&spec.value),
|
||||
// ...and the one store recipe that is NOT a hand-off: the resolved exe is the game itself.
|
||||
"gog" => gog_spawn(&spec.value).map(|(cmdline, workdir)| WinRecipe::game(cmdline, workdir)),
|
||||
// Xbox/Game Pass: activate the UWP/GDK package by its AUMID (<PFN>!<AppId>) via explorer's
|
||||
// shell:AppsFolder — which runs in the interactive user session (UWP activation fails as
|
||||
// SYSTEM/session-0; spawn_in_active_session uses the user token). Guard the charset (the value
|
||||
// is host-derived from MicrosoftGame.config + AppRepository, but belt-and-suspenders).
|
||||
"aumid" => valid_aumid(&spec.value).then(|| {
|
||||
(
|
||||
format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value),
|
||||
None,
|
||||
)
|
||||
WinRecipe::handoff(format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value))
|
||||
}),
|
||||
// Xbox / Game Pass from a library PLUGIN: `<Identity>!<AppId>`, both read straight out of
|
||||
// `MicrosoftGame.config`. The host completes it into the AUMID.
|
||||
@@ -287,10 +366,9 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
|
||||
return None;
|
||||
}
|
||||
let pfn = xbox_pfn(identity)?;
|
||||
Some((
|
||||
format!("explorer.exe \"shell:AppsFolder\\{pfn}!{app_id}\""),
|
||||
None,
|
||||
))
|
||||
Some(WinRecipe::handoff(format!(
|
||||
"explorer.exe \"shell:AppsFolder\\{pfn}!{app_id}\""
|
||||
)))
|
||||
}
|
||||
// Playnite: open the game through Playnite's own URI handler, which is what actually knows
|
||||
// how to start it (Playnite maps the id to whichever store owns the title). explorer.exe
|
||||
@@ -301,10 +379,10 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
|
||||
// line). The 2026-08-05 review made `command` operator-only, which refuses a plugin's whole
|
||||
// reconcile — so without a typed kind the Playnite plugin cannot publish anything at all.
|
||||
"playnite" => valid_playnite_id(&spec.value).then(|| {
|
||||
(
|
||||
format!("explorer.exe \"playnite://playnite/start/{}\"", spec.value),
|
||||
None,
|
||||
)
|
||||
WinRecipe::handoff(format!(
|
||||
"explorer.exe \"playnite://playnite/start/{}\"",
|
||||
spec.value
|
||||
))
|
||||
}),
|
||||
// A launcher entry (D4) on Windows: today that is Playnite's Fullscreen app, spawned
|
||||
// directly (its `playnite://` handler opens the DESKTOP app, so no URI can do this). The
|
||||
@@ -313,16 +391,18 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
|
||||
"launcher_ui" => match spec.value.as_str() {
|
||||
"playnite" => playnite_fullscreen_exe().map(|exe| {
|
||||
let dir = exe.parent().map(std::path::Path::to_path_buf);
|
||||
(format!("\"{}\"", exe.display()), dir)
|
||||
WinRecipe::game(format!("\"{}\"", exe.display()), dir)
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
// Operator-typed custom command (host-owned, never client-set): run it through the shell in the
|
||||
// interactive session. `cmd.exe /c` is acceptable here precisely because the value is operator
|
||||
// input — the same trust as the operator typing it — not a client-influenced string.
|
||||
// `cmd.exe /c <v>` blocks until the operator's command returns, so its pid tracks that
|
||||
// command's life — the Windows twin of the Linux child the host holds.
|
||||
"command" => {
|
||||
let v = spec.value.trim();
|
||||
(!v.is_empty()).then(|| (format!("cmd.exe /c {v}"), None))
|
||||
(!v.is_empty()).then(|| WinRecipe::game(format!("cmd.exe /c {v}"), None))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -744,7 +824,7 @@ pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
|
||||
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
|
||||
/// through the compositor-aware [`launch_session_command`] instead.
|
||||
#[cfg(windows)]
|
||||
pub fn launch_gamestream_command(cmd: &str) -> Result<u32> {
|
||||
pub fn launch_gamestream_command(cmd: &str) -> Result<WindowsLaunch> {
|
||||
let cmd = cmd.trim();
|
||||
anyhow::ensure!(!cmd.is_empty(), "empty command");
|
||||
// cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a
|
||||
@@ -752,9 +832,13 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<u32> {
|
||||
let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None)
|
||||
.context("spawn gamestream command in the interactive session")?;
|
||||
tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session");
|
||||
// The `cmd.exe` shim's own pid: it exits the moment it has started the real program, which the
|
||||
// lease reads as a hand-off (inside its shim window) rather than as the game exiting.
|
||||
Ok(pid)
|
||||
// `cmd.exe /c` waits for the operator's command, so this pid is the command's own life. Should
|
||||
// the command itself be a forwarder that returns at once, the lease's shim window is what reads
|
||||
// that as a hand-off rather than as the game exiting.
|
||||
Ok(WindowsLaunch {
|
||||
pid,
|
||||
owns_game: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
|
||||
@@ -763,7 +847,7 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<u32> {
|
||||
/// only ever pick an existing title — never inject a command. Linux resolves the id via
|
||||
/// [`resolve_launch`] and goes through [`launch_session_command`] instead.
|
||||
#[cfg(windows)]
|
||||
pub fn launch_gamestream_library(id: &str) -> Result<u32> {
|
||||
pub fn launch_gamestream_library(id: &str) -> Result<WindowsLaunch> {
|
||||
launch_title(id)
|
||||
}
|
||||
|
||||
@@ -1023,11 +1107,13 @@ mod tests {
|
||||
let Some(exe) = playnite_fullscreen_exe() else {
|
||||
return;
|
||||
};
|
||||
let (cmd, dir) = ui("playnite").expect("resolvable when the exe was found");
|
||||
let r = ui("playnite").expect("resolvable when the exe was found");
|
||||
let cmd = &r.cmdline;
|
||||
assert!(cmd.contains("Playnite.FullscreenApp.exe"), "{cmd}");
|
||||
assert!(!cmd.contains("DesktopApp"), "{cmd}");
|
||||
assert!(!cmd.contains("playnite://"), "{cmd}");
|
||||
assert_eq!(dir.as_deref(), exe.parent());
|
||||
assert_eq!(r.workdir.as_deref(), exe.parent());
|
||||
assert!(r.owns_game, "the exe is spawned directly, not forwarded");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -1078,11 +1164,20 @@ mod tests {
|
||||
value: v.into(),
|
||||
})
|
||||
};
|
||||
let (bp, wd) = ui("bigpicture").expect("bigpicture recipe");
|
||||
assert!(bp.contains("steam://open/bigpicture"), "line was {bp:?}");
|
||||
assert!(wd.is_none());
|
||||
let (desk, _) = ui("desktop").expect("desktop recipe");
|
||||
assert!(desk.contains("steam://open/main"), "line was {desk:?}");
|
||||
let bp = ui("bigpicture").expect("bigpicture recipe");
|
||||
assert!(
|
||||
bp.cmdline.contains("steam://open/bigpicture"),
|
||||
"line was {:?}",
|
||||
bp.cmdline
|
||||
);
|
||||
assert!(bp.workdir.is_none());
|
||||
assert!(!bp.owns_game, "a steam:// URI is forwarded to the client");
|
||||
let desk = ui("desktop").expect("desktop recipe");
|
||||
assert!(
|
||||
desk.cmdline.contains("steam://open/main"),
|
||||
"line was {:?}",
|
||||
desk.cmdline
|
||||
);
|
||||
assert!(ui("nonsense").is_none());
|
||||
assert!(ui("").is_none());
|
||||
}
|
||||
@@ -1162,9 +1257,10 @@ mod tests {
|
||||
kind: "steam_appid".into(),
|
||||
value: "570".into(),
|
||||
};
|
||||
let (line, wd) = windows_launch_for(&steam).expect("steam recipe");
|
||||
let steam_r = windows_launch_for(&steam).expect("steam recipe");
|
||||
let line = &steam_r.cmdline;
|
||||
assert!(line.contains("steam://rungameid/570"), "line was {line:?}");
|
||||
assert!(wd.is_none());
|
||||
assert!(steam_r.workdir.is_none());
|
||||
// A non-numeric "appid" (a client trying to inject) is rejected, never interpolated.
|
||||
let evil = LaunchSpec {
|
||||
kind: "steam_appid".into(),
|
||||
@@ -1176,9 +1272,11 @@ mod tests {
|
||||
kind: "command".into(),
|
||||
value: "notepad.exe".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
windows_launch_for(&cmd).unwrap().0,
|
||||
"cmd.exe /c notepad.exe"
|
||||
let cmd_r = windows_launch_for(&cmd).unwrap();
|
||||
assert_eq!(cmd_r.cmdline, "cmd.exe /c notepad.exe");
|
||||
assert!(
|
||||
cmd_r.owns_game,
|
||||
"`cmd /c` blocks on the operator's command, so its pid is that command's"
|
||||
);
|
||||
// Xbox AUMID → explorer shell:AppsFolder activation; a value without '!' is rejected.
|
||||
let aumid = LaunchSpec {
|
||||
@@ -1186,7 +1284,7 @@ mod tests {
|
||||
value: "Microsoft.X_8wekyb3d8bbwe!Game".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
windows_launch_for(&aumid).unwrap().0,
|
||||
windows_launch_for(&aumid).unwrap().cmdline,
|
||||
"explorer.exe \"shell:AppsFolder\\Microsoft.X_8wekyb3d8bbwe!Game\""
|
||||
);
|
||||
assert!(windows_launch_for(&LaunchSpec {
|
||||
|
||||
@@ -190,10 +190,25 @@ fn main() {
|
||||
);
|
||||
}
|
||||
|
||||
// Tee every panic through `tracing` BEFORE the default hook: a panicking thread otherwise
|
||||
// Tee every panic into the log ring BEFORE the default hook: a panicking thread otherwise
|
||||
// prints only to stderr — absent from the web console's Logs tab (the ring) and gone entirely
|
||||
// when stderr is detached — so a field report reads "host died, zero errors in the logs".
|
||||
// The default hook still runs afterwards for the usual stderr message/abort behavior.
|
||||
//
|
||||
// 🛑 **The tee goes straight to the ring, NOT through `tracing`.** A panic hook that emits a
|
||||
// tracing event is a trap: `tracing_subscriber`'s registry is `sharded-slab`-backed and reads a
|
||||
// `thread_local!` with `LocalKey::with`, so emitting from a thread whose TLS is being torn down
|
||||
// panics — *inside the hook*. Rust treats a panic raised while the hook is running as
|
||||
// `MustAbort::PanicInHook` and then deliberately does not format the message ("perhaps that is
|
||||
// causing the panic"), so the log gets `panicked at <loc>:` followed by a BLANK line and
|
||||
// `thread panicked while processing panic. aborting.` — the cause erased at exactly the moment
|
||||
// it mattered. That is precisely what hid the 2026-08-18 teardown abort on .173 (four aborts,
|
||||
// zero diagnosis) until it was reproduced standalone.
|
||||
//
|
||||
// Everything below is TLS-free and cannot panic: `LogRing` is a `OnceLock` + `Mutex`, and
|
||||
// `thread::current().name()` / `Backtrace::force_capture()` were both verified safe during TLS
|
||||
// destruction. This does not make a TLS-destructor panic survivable — Rust aborts on those
|
||||
// regardless — but it does mean the message that names the cause always lands.
|
||||
let default_panic = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
// Manual payload downcast (`payload_as_str` needs Rust 1.91; workspace MSRV is 1.82).
|
||||
@@ -203,14 +218,24 @@ fn main() {
|
||||
.copied()
|
||||
.or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
|
||||
.unwrap_or("<non-string panic payload>");
|
||||
tracing::error!(
|
||||
thread = std::thread::current().name().unwrap_or("<unnamed>"),
|
||||
location = %info
|
||||
.location()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| "<unknown>".into()),
|
||||
backtrace = %std::backtrace::Backtrace::force_capture(),
|
||||
"PANIC: {payload}"
|
||||
let location = info
|
||||
.location()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| "<unknown>".into());
|
||||
let thread = std::thread::current()
|
||||
.name()
|
||||
.unwrap_or("<unnamed>")
|
||||
.to_string();
|
||||
let backtrace = std::backtrace::Backtrace::force_capture();
|
||||
let ts_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
log_capture::ring().push_remote(
|
||||
"ERROR",
|
||||
"punktfunk_host::panic",
|
||||
&format!("PANIC: {payload} (thread={thread}, at {location})\n{backtrace}"),
|
||||
ts_ms,
|
||||
);
|
||||
default_panic(info);
|
||||
}));
|
||||
|
||||
@@ -1913,8 +1913,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
let mut spawned_now = false;
|
||||
// The pid Windows hands back for the process it started, kept so the lease has something of its
|
||||
// own to watch and to signal even when the title carries no detect signals at all (see
|
||||
// `gamelease::LeaseRequest::spawned`). `None` on every other platform and whenever nothing was
|
||||
// spawned.
|
||||
// `gamelease::LeaseRequest::spawned`). `None` on every other platform, whenever nothing was
|
||||
// spawned, and — crucially — whenever what was spawned is a protocol hand-off rather than the
|
||||
// game (`library::WinRecipe::owns_game`): a forwarder's pid is not a lifetime signal.
|
||||
#[allow(unused_mut)]
|
||||
let mut spawned_pid: Option<u32> = None;
|
||||
// Close whatever this client had running before, if the operator asked for that
|
||||
@@ -1941,8 +1942,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
);
|
||||
} else {
|
||||
match crate::library::launch_title(id) {
|
||||
Ok(pid) => {
|
||||
spawned_pid = Some(pid);
|
||||
Ok(launched) => {
|
||||
spawned_pid = launched.tracked_pid();
|
||||
spawned_now = true;
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user