Compare commits

...
Author SHA1 Message Date
enricobuehler 4cd35e15ca fix(host): one info! line in a TLS destructor aborted the host on every session teardown
ci / bun-nix (pull_request) Successful in 20s
ci / web (pull_request) Successful in 1m2s
ci / rust (pull_request) Successful in 5m58s
ci / rust-arm64 (pull_request) Successful in 7m18s
ci / docs-site (pull_request) Successful in 8m26s
android / android (pull_request) Successful in 14m11s
A GameStream session ending killed the whole `punktfunk-host` process on Windows.
The SCM restarted it ~6 s later, so in the field it read as a mystery reconnect
rather than a crash. Four occurrences on .173, every one of them a session
teardown, on the canary and on a branch build alike.

`HotThreadGuard` is parked in a `thread_local!` so the last hot thread out
reverts the process-wide session tuning. Its `Drop` is therefore a **TLS
destructor**, and it called `tracing::info!`. By then this thread's other
thread-locals may already be gone — `tracing_subscriber`'s registry is
`sharded-slab`-backed and reads a `thread_local!` through `LocalKey::with` — so
the event panicked with "cannot access a Thread Local Storage value during or
after destruction". A panic that escapes a TLS destructor is fatal in Rust:
`fatal runtime error: thread local panicked on drop, aborting`.

The panic hook then hid the evidence. It teed through `tracing` too, so it
panicked the same way — and a panic raised while the hook runs is
`MustAbort::PanicInHook`, where std deliberately does not format the message
("perhaps that is causing the panic"). The log got `panicked at <loc>:`, a BLANK
line, and `thread panicked while processing panic. aborting.` The one string
naming the cause was erased at exactly the moment it mattered.

* `untune_process` no longer logs. The revert is only FFI and stays inside the
  refcount lock, so it is still atomic against a session starting concurrently.
  Its counterpart "applied" line runs on a live thread and is kept.
* The panic hook writes straight to the `LogRing` instead of through `tracing`.
  The ring is a `OnceLock` + `Mutex`, so it is TLS-free; `thread::current()` and
  `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 the message that names the cause now always lands.

Reproduced standalone before fixing, against the same 1.96.0 toolchain, and the
output is byte-identical to the field log down to the blank line. That harness
also settles which half is load-bearing: destructor-logging alone aborts, hook
hardening alone still aborts (with a readable message), and removing the
destructor's log exits cleanly.
2026-08-18 10:30:35 +02:00
enricobuehler 700275fa0d Merge pull request 'A Windows launch is a hand-off, and 0.30 read its exit as the game's' (#288) from worktree-win-game-launch-untracked into main
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m24s
ci / rust-arm64 (push) Successful in 1m31s
deb / build-publish-gamescope (push) Successful in 21s
deb / build-publish-client-arm64 (push) Successful in 1m15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 7s
deb / build-publish (push) Successful in 2m49s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
ci / bun-nix (push) Successful in 4m35s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
deb / build-publish-host (push) Successful in 3m57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
docker / builders-arm64cross (push) Successful in 7s
deb / smoke-install (push) Successful in 2m36s
docker / deploy-docs (push) Successful in 30s
windows-host / package (push) Successful in 14m7s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
arch / build-publish (push) Successful in 16m14s
android / android (push) Successful in 19m8s
ci / rust (push) Successful in 19m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m28s
Reviewed-on: #288
2026-08-18 08:19:50 +00:00
enricobuehler 6352ff629d fix(host/library): a Windows launch is a hand-off, and 0.30 read its exit as the game's
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m27s
ci / bun-nix (pull_request) Successful in 1m41s
android / android (pull_request) Successful in 12m3s
ci / rust (pull_request) Successful in 16m5s
A user launched a title on a 0.30 Windows host and watched the game come up while
the stream dropped, with the console reporting nothing running. Two changes from
0.30 meet here, and the second turns the first into a disconnect.

`LeaseRequest::spawned` (0.30) started carrying the pid `CreateProcessAsUserW`
hands back, so a Windows launch could finally be a `LeaseKind::Child` instead of
degrading to `Untracked`. But `windows_launch_for` builds a protocol hand-off for
almost every kind — `explorer.exe "playnite://…"`, `Steam.exe "steam://…"`,
`explorer.exe "shell:AppsFolder\…"` — so that pid is a forwarder's, not the
game's, and it says nothing about the game in either direction: it quits a second
after launch when the launcher was already running, and it *becomes* the launcher
when it was not.

The watcher's shim window exists for exactly this, and both callers of the pid
path documented it as their protection — but it was skipped whenever the title
had no detect signals, which is the one shape that needs it. So a hint-less title
(a Playnite entry whose install dir Playnite does not know, a custom entry with no
hint) reported `running` on its first poll because the forwarder was alive, and
`exited` a second later when the forwarder quit: `finish` fired `on_exit`, which
closes the connection with `APP_EXITED`. In 0.29 the same title was `Untracked`,
nothing watched it, and the session stayed up — which is the "it used to just show
as running" the report describes.

The forwarder pid is not only a false liveness signal, it is a termination target:
`windows_term_ladder` adds `shared.spawned` to the pids it `WM_CLOSE`s and then
terminates. A Steam title launched while Steam was closed makes that pid the Steam
*client*, so `POST /game/end`, a grace expiry or `game_on_session_end` would ask
the player's whole Steam client to close rather than the game.

* The Windows recipe table now says which lines start the game and which only
  forward it (`WinRecipe::owns_game`); a forwarder's pid is dropped rather than
  carried, so the lease falls back to detect signals exactly as it did before.
  Only `gog` (the resolved exe), `command` and a plugin's own recipe (`cmd.exe /c`
  blocks on them) own their game.
* The shim window now applies to a bare child or pid whatever the spec holds. An
  empty spec is fewer reasons to trust the child, not more.
* Giving up on tracking lands the lease on `GameState::Untracked` instead of
  leaving the console on `launching` forever — the same honest answer `open`
  reaches when it starts no watcher at all.

`a_pid_only_launch_reports_its_exit` used a 4-second fixture, inside the shim
window; it passed only because of the bug above. It is 8 seconds now, and a new
ignored test drives the field report itself: a pid that hands off inside the
window with no signals must not end the session, and must leave the row
`untracked`. Backing the phase-1 change out fails that test with `on_exit` fired
once — the disconnect, reproduced.

Gates. Windows .173: `clippy --all-targets -D warnings` clean (non-vacuous), and
`library::launch` 11 + `gamelease` 12 tests pass. Linux (punktfunk-rust-ci): fmt
clean, `clippy --all-targets -D warnings` clean, `gamelease` 14 pass, and all
three `--ignored` live-process tests pass.
2026-08-18 09:30:57 +02:00
6 changed files with 303 additions and 78 deletions
+19 -4
View File
@@ -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 {
+97 -12
View File
@@ -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) => {
+144 -46
View File
@@ -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 {
+34 -9
View File
@@ -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);
}));
+5 -4
View File
@@ -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) => {