feat(host/hooks): per-app prep/undo commands (M2b — Sunshine prep-cmd parity)
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Successful in 4m22s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m3s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m3s
android / android (push) Successful in 17m6s
arch / build-publish (push) Successful in 16m51s
deb / build-publish (push) Successful in 12m16s
ci / rust (push) Successful in 25m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m17s
windows-host / package (push) Has been cancelled
docker / deploy-docs (push) Failing after 19s

`prep: [{"do": …, "undo": …}]` arrays on GameStream apps.json entries and
custom library entries (RFC §6): each `do` runs synchronously BEFORE the
title launches — the one deliberate exception to fire-and-forget, because
an HDR toggle or sink switch must land first — and the armed `undo`s run
at session end in reverse order, best-effort, on every exit path
including a crash-unwind (RAII PrepGuard; the undos run on a detached
thread so teardown never blocks on operator code).

- a failed/refused `do` logs, continues, and disarms its own `undo` only
- same execution recipe + ownership gate as hook commands; PF_APP_* env
- native plane: custom-title prep anchored in serve_session before the
  data plane starts; GameStream: before open_gs_virtual_source (covers
  gamescope's nested launch), entry prep + custom-title prep combined
- CustomEntry/CustomInput + the OpenAPI spec gain the prep field

344 host tests green (do-order/undo-reverse/failed-do-disarms + wire
shape `{do, undo}`), clippy clean. On-glass with a real client session
owed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 22:02:35 +02:00
co-authored by Claude Opus 4.8
parent 384f8e00aa
commit 63efe0ecd5
6 changed files with 276 additions and 13 deletions
+180 -13
View File
@@ -423,8 +423,15 @@ fn exec_path_check(_cmd: &str) -> Result<(), String> {
}
/// Run one hook command to completion (or timeout), blocking the reaper thread it runs on.
/// Returns whether the command ran to completion successfully (exit 0) — the prep machinery
/// gates each step's `undo` on it.
#[cfg(unix)]
fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeout: Duration) {
fn run_hook_process(
cmd: &str,
event_json: &str,
env: &[(String, String)],
timeout: Duration,
) -> bool {
use std::io::Write;
use std::os::unix::process::CommandExt;
let mut c = std::process::Command::new("/bin/sh");
@@ -440,7 +447,7 @@ fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeo
Ok(ch) => ch,
Err(e) => {
tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch");
return;
return false;
}
};
if let Some(mut stdin) = child.stdin.take() {
@@ -454,7 +461,7 @@ fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeo
if !status.success() {
tracing::warn!(cmd = %cmd, %status, "hook command exited non-zero");
}
return;
return status.success();
}
Ok(None) => {
if Instant::now() >= deadline {
@@ -469,13 +476,13 @@ fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeo
#[cfg(not(target_os = "linux"))]
let _ = child.kill();
let _ = child.wait(); // reap — never leave a zombie
return;
return false;
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
tracing::warn!(cmd = %cmd, error = %e, "hook command wait failed");
return;
return false;
}
}
}
@@ -487,7 +494,12 @@ fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeo
/// appended as the command's last argument. A console-mode host (dev) falls back to a plain
/// spawn with the full Unix-style context (env + stdin).
#[cfg(windows)]
fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeout: Duration) {
fn run_hook_process(
cmd: &str,
event_json: &str,
env: &[(String, String)],
timeout: Duration,
) -> bool {
use std::io::Write;
let stamp = format!(
"pf-hook-{}-{}.json",
@@ -508,10 +520,14 @@ fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeo
// No child handle on this path — wait out the timeout, then clean the temp file.
std::thread::sleep(timeout);
let _ = std::fs::remove_file(&json_path);
// Detached in the user session: completion/exit status is unobservable here —
// report "ran" (prep `undo`s stay armed).
true
}
Err(e) => {
tracing::debug!(error = %format!("{e:#}"),
"interactive-session spawn unavailable — running hook in-console");
let mut ok = false;
let mut c = std::process::Command::new("cmd.exe");
c.arg("/C")
.arg(cmd)
@@ -525,19 +541,26 @@ fn run_hook_process(cmd: &str, event_json: &str, env: &[(String, String)], timeo
let _ = stdin.write_all(event_json.as_bytes());
}
let deadline = Instant::now() + timeout;
while child.try_wait().ok().flatten().is_none() {
if Instant::now() >= deadline {
tracing::warn!(cmd = %cmd, "hook command timed out — killing it");
let _ = child.kill();
let _ = child.wait();
break;
loop {
match child.try_wait().ok().flatten() {
Some(status) => {
ok = status.success();
break;
}
None if Instant::now() >= deadline => {
tracing::warn!(cmd = %cmd, "hook command timed out — killing it");
let _ = child.kill();
let _ = child.wait();
break;
}
None => std::thread::sleep(Duration::from_millis(100)),
}
std::thread::sleep(Duration::from_millis(100));
}
}
Err(e) => tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch"),
}
let _ = std::fs::remove_file(&json_path);
ok
}
}
}
@@ -604,6 +627,88 @@ fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
}
}
// ------------------------------------------------------------------------- per-app prep/undo
/// One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs
/// **synchronously before the app launches** (an HDR toggle or a MangoHud env change must land
/// first), `undo` runs at session end — reverse order across steps, best-effort, on every exit
/// path including a crash-unwind (RAII via [`PrepGuard`]).
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)]
pub struct PrepCmd {
/// Command run before launch. Same execution recipe and ownership checks as hook `run`
/// commands (event-less: stdin is empty JSON, env carries the `PF_APP_*` context).
#[serde(rename = "do")]
pub run: String,
/// Command run after the session ends. Skipped when its `do` failed (it never took effect).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub undo: Option<String>,
}
/// Holds the armed `undo` commands for one session's prep steps; dropping it (session end,
/// error return, panic-unwind) runs them in reverse order on a detached thread — teardown
/// never blocks on operator code.
#[must_use = "dropping the guard immediately runs the undo commands"]
pub struct PrepGuard {
undo: Vec<String>,
env: Vec<(String, String)>,
}
/// Run a title's prep steps **synchronously, in order** (the caller is a launch path — this is
/// the one deliberate exception to fire-and-forget, because prep exists to happen *before* the
/// game). Each step gets the default hook timeout and the same ownership gate as hook
/// commands; a failed/refused `do` logs and continues (best-effort), and its `undo` stays
/// disarmed. Returns the guard that runs the armed `undo`s at drop.
pub fn run_prep(cmds: &[PrepCmd], env: &[(String, String)]) -> PrepGuard {
let timeout = Duration::from_secs(u64::from(DEFAULT_TIMEOUT_S));
let mut undo = Vec::new();
for c in cmds {
let cmd = c.run.trim();
if cmd.is_empty() {
continue;
}
if let Err(e) = exec_path_check(cmd) {
tracing::error!(cmd = %cmd, "REFUSING prep command — {e}");
continue;
}
tracing::info!(cmd = %cmd, "prep: running");
if run_hook_process(cmd, "{}", env, timeout) {
if let Some(u) = c.undo.as_deref().filter(|u| !u.trim().is_empty()) {
undo.push(u.to_string());
}
} else if c.undo.is_some() {
tracing::warn!(cmd = %cmd, "prep step failed — its undo is skipped");
}
}
PrepGuard {
undo,
env: env.to_vec(),
}
}
impl Drop for PrepGuard {
fn drop(&mut self) {
if self.undo.is_empty() {
return;
}
let undo = std::mem::take(&mut self.undo);
let env = std::mem::take(&mut self.env);
let timeout = Duration::from_secs(u64::from(DEFAULT_TIMEOUT_S));
// Detached: the drop site may be an async task or a panic-unwind — session teardown
// must not block on operator commands. Order (reverse of `do`) is preserved because
// the one thread runs them sequentially.
std::thread::spawn(move || {
for cmd in undo.iter().rev() {
if let Err(e) = exec_path_check(cmd) {
tracing::error!(cmd = %cmd, "REFUSING prep undo command — {e}");
continue;
}
tracing::info!(cmd = %cmd, "prep: running undo");
run_hook_process(cmd, "{}", &env, timeout);
}
});
}
}
// ------------------------------------------------------------------------- tests
#[cfg(test)]
@@ -816,6 +921,68 @@ mod tests {
);
}
/// Prep semantics end to end: `do`s run in order before the guard exists, armed `undo`s run
/// in REVERSE order at drop, and a failed `do` disarms its own `undo` only.
#[cfg(unix)]
#[test]
fn prep_runs_do_in_order_and_undo_in_reverse() {
let out = std::env::temp_dir().join(format!(
"pf-prep-test-{}-{:p}.txt",
std::process::id(),
&0u8 as *const u8
));
let _ = std::fs::remove_file(&out);
let step = |do_tag: &str, undo_tag: Option<&str>| PrepCmd {
run: format!("echo {do_tag} >> {}", out.display()),
undo: undo_tag.map(|t| format!("echo {t} >> {}", out.display())),
};
let cmds = vec![
step("do-a", Some("undo-a")),
step("do-b", Some("undo-b")),
// A failing `do` must not arm its undo.
PrepCmd {
run: "false".into(),
undo: Some(format!("echo undo-never >> {}", out.display())),
},
];
let guard = run_prep(&cmds, &[]);
let text = std::fs::read_to_string(&out).expect("prep steps ran synchronously");
assert_eq!(text, "do-a\ndo-b\n", "dos run in order, before launch");
drop(guard);
// The undo thread is detached — poll for its completion.
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let text = std::fs::read_to_string(&out).unwrap_or_default();
if text.lines().count() >= 4 {
assert_eq!(
text, "do-a\ndo-b\nundo-b\nundo-a\n",
"undos run in reverse; the failed step's undo is skipped"
);
break;
}
assert!(Instant::now() < deadline, "undo thread never ran: {text}");
std::thread::sleep(Duration::from_millis(50));
}
// Give the skipped-undo a beat to (wrongly) appear, then assert it didn't.
std::thread::sleep(Duration::from_millis(200));
assert!(!std::fs::read_to_string(&out)
.unwrap()
.contains("undo-never"));
let _ = std::fs::remove_file(&out);
}
#[test]
fn prep_cmd_wire_shape() {
// The RFC's `{ "do": …, "undo": … }` spelling is the wire contract.
let c: PrepCmd = serde_json::from_str(r#"{"do":"a","undo":"b"}"#).unwrap();
assert_eq!(c.run, "a");
assert_eq!(c.undo.as_deref(), Some("b"));
let c: PrepCmd = serde_json::from_str(r#"{"do":"a"}"#).unwrap();
assert!(c.undo.is_none());
assert_eq!(serde_json::to_string(&c).unwrap(), r#"{"do":"a"}"#);
}
#[cfg(unix)]
#[test]
fn ownership_check_refuses_world_writable_scripts() {