From d5fb1e447995ab275ef5d06bfd6cce0e575634c0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 20 Aug 2026 19:00:51 +0200 Subject: [PATCH 1/3] feat(host): a provider plugin can report which of its titles are running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host derives liveness by scanning, which needs something recognizable on disk. A Playnite-launched emulated game, a manually added one, or a library plugin that records no install directory has none — and its launch is a `playnite://` hand-off, so the host holds no process either. The lease went `Untracked`: the exit was never noticed, `session_on_game_exit` could not fire, and `POST /game/end` had nothing to aim at. Playnite knew the whole time. New `PUT /library/provider/{provider}/running` takes a provider's complete running set (with the pid where it knows one) — declarative and idempotent like the reconcile beside it, so a missed event or a plugin restart self-corrects rather than drifting. `crate::runstate` holds it and expires it after 90s unless restated, which is what makes it safe for a live provider to hold a session open for a game the host cannot see: a plugin that dies stops counting and the host falls back to scanning, exactly as today. `LeaseKind::Reported` is the lease that follows from it. `open` reaches it when the spec is empty and a provider speaks for the id, and — the load-bearing part on Windows, where every launch is a hand-off by construction — the three shim reclassification paths now fall back to it where they fell to `Untracked`. Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the exit. Unlike `procscan::running_hint`, which may only ever delay an exit because Steam's registry flag survives an unclean one, a fresh report is decisive in both directions. A reported pid joins the termination ladders on the same terms as a spawned one: re-resolved and start-time-pinned at the moment of use. The route is the plugin lane's, like the reconcile. No new authority — the host maps `external_id` through the catalog, so a provider can only speak about entries it published; an unknown id is counted, not refused, because a report legitimately races its own reconcile and 400-ing the batch would throw away the liveness of every other running title. plugin-kit gains `ProviderClient.reportRunning`; a 404 from an older host means "this host tracks games by scanning". --- CHANGELOG.md | 44 +++++ crates/punktfunk-host/src/gamelease.rs | 216 +++++++++++++++++---- crates/punktfunk-host/src/main.rs | 3 + crates/punktfunk-host/src/mgmt.rs | 1 + crates/punktfunk-host/src/mgmt/auth.rs | 4 + crates/punktfunk-host/src/mgmt/library.rs | 118 ++++++++++++ crates/punktfunk-host/src/mgmt/tests.rs | 61 ++++++ crates/punktfunk-host/src/runstate.rs | 223 ++++++++++++++++++++++ plugin-kit/src/reconcile.ts | 82 ++++++++ 9 files changed, 716 insertions(+), 36 deletions(-) create mode 100644 crates/punktfunk-host/src/runstate.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d74337d..2c218fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -537,6 +537,50 @@ legs as follow-ups. Both landed here. ring layer's line shape; `nativeRenderLogs(header)` hands Kotlin the rendered bundle, and the upload rides the client's own mTLS. +### A provider plugin can report which of its titles are **running** + +New: `PUT /api/v1/library/provider/{provider}/running`, body +`{"running":[{"external_id":"…","pid":1234}]}` — the **live** counterpart to the static `detect` +hints a reconcile carries. `detect` says *how to recognize* a title's process; this says *it is +running now*, and carries the pid where the provider knows one. Additive: no existing route, +payload or behaviour changes, and a host with no reporting plugin behaves exactly as before. + +It exists because one class of title could never be tracked at all. The host derives liveness by +scanning (`procscan` + `DetectSpec`), which needs something recognizable on disk — an install +directory, an executable, a Steam reaper. A Playnite-launched emulated game, a manually added one, +or a library plugin that records no install directory has none of that, and its launch is a +`playnite://` hand-off, so the host holds no process either: the lease went `Untracked`, its exit +was never noticed, `session_on_game_exit` could not fire, and `POST /game/end` had nothing to aim +at. Playnite knew the whole time — it starts the game, tracks it in the mode the person configured, +and fires an event on both edges carrying the pid. That was being thrown away. + +- **Declarative and idempotent**, like the reconcile beside it: the body is the provider's + **complete** running set, so a missed event, a plugin restart or an install mid-game self-correct + on the next report instead of drifting. Absent from the set = stopped. +- **Reports expire** (`crate::runstate::REPORT_TTL`, 90 s; the answer carries `ttl_s`). This is what + makes it safe for a live provider to hold a streaming session open for a game the host cannot + see: a plugin that dies with a game running stops counting shortly after and the host falls back + to scanning. Reporters must restate well inside the window. +- **New `gamelease::LeaseKind::Reported`** — a lease with no process signal of its own, tracked by + what its provider says. `open` reaches it when the spec is empty and a provider speaks for the id; + the shim-reclassification paths (every Windows launch is a hand-off by construction) fall back to + it too, where they previously fell to `Untracked`. Phase 1 accepts "running" as the game + appearing; phase 2 treats "stopped" as the exit, and — unlike `procscan::running_hint`, which may + only ever *delay* an exit because Steam's registry flag survives an unclean exit — a fresh + provider report is decisive in both directions. A reported pid joins the termination ladders on + the same terms as a spawned one (re-resolved and start-time-pinned at the moment of use). +- **Route authority**: the plugin lane, like the reconcile (`mgmt::auth::plugin_may_access`, and its + exhaustive classification table). No new authority — the host maps `external_id` through the + catalog, so a provider can only ever speak about entries it published; an unknown id is *counted*, + not refused, because a report legitimately races its own reconcile and 400-ing the batch would + throw away the liveness of every other running title. +- **`@punktfunk/plugin-kit`: `ProviderClient.reportRunning(providerId, running)`**, returning + `{matched, unknown, ttlS}`; a 404 from an older host means "this host tracks games by scanning". + Rides the owed `plugin-kit-v0.4.3` publish. + +The Playnite half (the C# exporter hooking `OnGameStarted`/`OnGameStopped` and the plugin relaying +it on a heartbeat) lives in `punktfunk-plugin-playnite` and needs a host carrying this route. + ### Everything else an integrator might notice - **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin, diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs index f66e0f38..62d9b6af 100644 --- a/crates/punktfunk-host/src/gamelease.rs +++ b/crates/punktfunk-host/src/gamelease.rs @@ -114,9 +114,18 @@ pub enum LeaseKind { Child, /// A launcher owns the game; it is recognized by its [`DetectSpec`]. Matched, - /// Nothing identifies this title's process — no detect signals and no child we own. Both - /// lifetime behaviors stay inert for it, and the host says so once in the log rather than - /// guessing. + /// A launcher owns the game and **tells us** when it starts and stops + /// ([`crate::runstate`]) — no process signal of our own. + /// + /// The one lease kind whose liveness the host does not determine for itself, and the answer to + /// a title that has nothing to scan for: Playnite launches an emulated or manually-added game + /// through its own tracking and reports the edges, where the host could see only a + /// `playnite://` forwarder exiting. Before this such a title was [`Untracked`](Self::Untracked) + /// — the honest answer at the time, and a dead end. + Reported, + /// Nothing identifies this title's process — no detect signals, no child we own, and no + /// provider reporting on it. Both lifetime behaviors stay inert for it, and the host says so + /// once in the log rather than guessing. Untracked, } @@ -126,6 +135,7 @@ impl LeaseKind { Self::Nested => "nested", Self::Child => "child", Self::Matched => "matched", + Self::Reported => "reported", Self::Untracked => "untracked", } } @@ -387,6 +397,12 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease { LeaseKind::Child } else if !spec.is_empty() { LeaseKind::Matched + } else if crate::runstate::speaks_for(game.id.as_deref()) { + // Nothing to scan for, but the provider that published this title is reporting liveness for + // it — so it is tracked after all. Asked once, here, rather than every poll: a lease's kind + // is what decides whether it is watched at all, and a title that flipped kind mid-flight + // would make both lifetime behaviors depend on a plugin's uptime. + LeaseKind::Reported } else { LeaseKind::Untracked }; @@ -551,6 +567,27 @@ fn watch( s.is_some_and(|p| !scanner.alive(&[p]).is_empty()) }; + // What this title's provider says about it, when one reports at all ([`crate::runstate`]) — + // `None` on every host with no reporting plugin, which is what keeps all of this inert until + // someone opts in. Re-read each poll rather than captured: the whole value of it is that it + // changes while the lease is alive. + let reported = || shared.game.id.as_deref().and_then(crate::runstate::opinion); + + // What a `Child` lease falls back to once its child turns out to be a shim: the store's own + // signals, else the provider's reporting, else nothing. The same ladder [`open`] walks, minus + // the child that has just gone away — and the reason a hint-less Playnite title is tracked at + // all on Windows, where the launch is `explorer.exe "playnite://…"` and therefore ALWAYS a + // hand-off, so every such lease arrives here. + let fallback_kind = || { + if !shared.spec.is_empty() { + LeaseKind::Matched + } else if crate::runstate::speaks_for(shared.game.id.as_deref()) { + LeaseKind::Reported + } else { + LeaseKind::Untracked + } + }; + // ---- Phase 1: wait for the game to show up. ---- let start_deadline = spawned_at + START_GRACE; loop { @@ -567,8 +604,10 @@ fn watch( && !spawned_up(&spawned) { spawned = None; - if spawned_at.elapsed() < SHIM_WINDOW { - if shared.spec.is_empty() { + let quick = spawned_at.elapsed() < SHIM_WINDOW; + kind = fallback_kind(); + if quick { + if matches!(kind, LeaseKind::Untracked) { tracing::info!( title = %shared.game.title, "the launch command exited immediately (a launcher handing off) and this \ @@ -582,11 +621,10 @@ fn watch( } tracing::debug!( title = %shared.game.title, - "the launch command handed off and exited — recognizing the game by its store \ - signals instead" + kind = kind.as_str(), + "the launch command handed off and exited — recognizing the game another way" ); - kind = LeaseKind::Matched; - } else if shared.spec.is_empty() { + } else if matches!(kind, LeaseKind::Untracked) { // It ran long enough to have BEEN the game, and nothing else identifies it. shared.was_running.store(true, Ordering::Relaxed); finish(&shared, &on_exit, "the launched process exited"); @@ -604,31 +642,30 @@ fn watch( shared.forget_child(); if quick && status.success() { // A launcher that handed the game off and exited. Fall back to recognizing - // the game by its store's signals; with none, stop tracking entirely rather - // than pretend the shim's exit was the game's. - kind = if shared.spec.is_empty() { + // the game by its store's signals (or its provider's reporting); with + // neither, stop tracking entirely rather than pretend the shim's exit was + // the game's. + kind = fallback_kind(); + if matches!(kind, LeaseKind::Untracked) { tracing::info!( title = %shared.game.title, "the launch command exited immediately (a launcher handing off) and \ this title has no detect signals — stopping game tracking for it" ); - LeaseKind::Untracked - } else { - tracing::debug!( - title = %shared.game.title, - "the launch command handed off and exited — recognizing the game by \ - its store signals instead" - ); - LeaseKind::Matched - }; - if matches!(kind, LeaseKind::Untracked) { shared.set_state(GameState::Untracked); return; } + tracing::debug!( + title = %shared.game.title, + kind = kind.as_str(), + "the launch command handed off and exited — recognizing the game \ + another way" + ); } else { // It ran long enough to have BEEN the game (or failed outright). Either way // the game is gone; only a success after a real run counts as "played". - if shared.spec.is_empty() { + kind = fallback_kind(); + if matches!(kind, LeaseKind::Untracked) { if spawned_at.elapsed() >= SHIM_WINDOW { shared.was_running.store(true, Ordering::Relaxed); finish(&shared, &on_exit, "the launched process exited"); @@ -642,11 +679,7 @@ fn watch( Some(Err(e)) => { tracing::debug!(error = %e, "could not poll the launched child — falling back to scanning"); child = None; - kind = if shared.spec.is_empty() { - LeaseKind::Untracked - } else { - LeaseKind::Matched - }; + kind = fallback_kind(); if matches!(kind, LeaseKind::Untracked) { shared.set_state(GameState::Untracked); return; @@ -680,7 +713,12 @@ fn watch( && (child.is_some() || spawned.is_some()) && spawned_at.elapsed() >= SHIM_WINDOW; let live = scanner.find(&shared.spec, shared.launch_stamp); - if !live.is_empty() || child_alive { + // A provider saying so is as good as seeing it — better, for a title there is nothing to + // see: it is the launcher that started the game telling us it did. This is the only way a + // [`LeaseKind::Reported`] lease ever leaves this phase, and for a `Matched` one it just + // gets there sooner than the scan would. + let said_running = reported().is_some_and(|l| l.running); + if !live.is_empty() || child_alive || said_running { known = live.clone(); publish(&live); shared.was_running.store(true, Ordering::Relaxed); @@ -754,6 +792,27 @@ fn watch( gone_since = None; vetoed = false; shared.last_seen_ms.store(now_ms(), Ordering::Relaxed); + } else if let Some(said) = reported() { + // Nothing of the game is visible to us, but its provider is still reporting on it — and + // that report is decisive in BOTH directions, where `running_hint` below may only ever + // delay an exit. + // + // The difference is what backs each claim. Steam's registry flag is a leftover that + // survives an unclean exit, so believing it indefinitely produces a session that never + // ends; a provider report is an event from the launcher that started the game, restated + // continuously, and it stops counting the moment it goes stale + // ([`crate::runstate::REPORT_TTL`]) — after which this branch simply stops being taken + // and the scan-only path below resumes. So a *live* provider is allowed to hold the + // session open for a game the host cannot see at all, which is the entire point for a + // title with no detect signals, and a dead one costs at most one TTL. + if said.running { + gone_since = None; + vetoed = false; + shared.last_seen_ms.store(now_ms(), Ordering::Relaxed); + } else { + finish(&shared, &on_exit, "its provider reported the game stopped"); + return; + } } else { // How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by // the veto below — letting it run on is exactly what bounds the veto. @@ -909,7 +968,7 @@ fn terminate_blocking(shared: &LeaseShared) { "released the nested session's kept display to end its game" ); } - LeaseKind::Child | LeaseKind::Matched => { + LeaseKind::Child | LeaseKind::Matched | LeaseKind::Reported => { #[cfg(target_os = "linux")] unix_term_ladder(shared); #[cfg(windows)] @@ -919,6 +978,26 @@ fn terminate_blocking(shared: &LeaseShared) { } } +/// The process this lease's provider reports for its game, re-resolved and pinned to its start +/// time, or `None`. +/// +/// The reason the wire carries a pid at all: for a [`LeaseKind::Reported`] title the matcher finds +/// nothing by construction, so without this "End" would have no target and would silently do +/// nothing — the exact failure a spawned pid was folded into the Windows ladder to fix. Resolved at +/// the moment of use rather than stored on the lease, so a report that has since gone stale, or a +/// pid the kernel has since recycled, contributes nothing. +#[cfg(any(target_os = "linux", windows))] +fn reported_proc(shared: &LeaseShared) -> Option { + let pid = shared + .game + .id + .as_deref() + .and_then(crate::runstate::opinion) + .filter(|l| l.running)? + .pid?; + crate::procscan::resolve(pid) +} + /// SIGTERM everything that belongs to the game, wait, then SIGKILL whatever ignored it. /// /// Every pid is re-verified against its recorded start time immediately before each signal, so a pid @@ -942,11 +1021,22 @@ fn unix_term_ladder(shared: &LeaseShared) { // `OwnedChild::group_leader`) — never for a child sharing the host's own group. unsafe { libc::kill(target, sig) == 0 } }; + // Everything the matcher can find, plus the pid the provider reported (see `reported_proc`) — + // which for a `Reported` lease is the only member of this set. + let targets = || { + let mut procs = scanner.find(&shared.spec, shared.launch_stamp); + if let Some(p) = reported_proc(shared) { + if !procs.iter().any(|q| q.pid == p.pid) { + procs.push(p); + } + } + procs + }; let signal_matched = |sig: i32| -> usize { // Re-scan and re-verify immediately before signalling, so a pid recycled since the last // sweep is never hit. scanner - .alive(&scanner.find(&shared.spec, shared.launch_stamp)) + .alive(&targets()) .into_iter() // SAFETY: as above, for a single pid just re-verified to be the process we adopted. .filter(|p| unsafe { libc::kill(p.pid as i32, sig) == 0 }) @@ -965,9 +1055,7 @@ fn unix_term_ladder(shared: &LeaseShared) { let deadline = Instant::now() + TERM_GRACE; while Instant::now() < deadline { std::thread::sleep(POLL); - let still = scanner - .alive(&scanner.find(&shared.spec, shared.launch_stamp)) - .len(); + let still = scanner.alive(&targets()).len(); // Signal 0 only probes for existence — the child (or its group) is gone once it fails. let child_gone = !signal_child(0); if still == 0 && child_gone { @@ -1000,11 +1088,19 @@ fn windows_term_ladder(shared: &LeaseShared) { let live = || { let mut procs = scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp)); // Re-verified like everything else, so a dead or recycled pid contributes nothing, and - // de-duplicated: the matcher may well have found this same process by its image. - if let Some(p) = shared.spawned { + // de-duplicated: the matcher may well have found this same process by its image. The + // provider's reported pid joins on the same terms, and for a `Reported` lease it is the + // only thing here (see `reported_proc`). + let mut fold = |p: crate::procscan::ProcRef| { if !scanner.alive(&[p]).is_empty() && !procs.iter().any(|q| q.pid == p.pid) { procs.push(p); } + }; + if let Some(p) = shared.spawned { + fold(p); + } + if let Some(p) = reported_proc(shared) { + fold(p); } procs }; @@ -1570,6 +1666,54 @@ mod tests { assert!(!l.shared().is_trackable()); } + /// A title with nothing to scan for is tracked after all when its provider reports on it. + /// + /// This is the Playnite case the static `detect` hints could never reach: an emulated game, a + /// manually added one, a library plugin that records no install directory. The launch is a + /// `playnite://` hand-off, so the host holds nothing; the spec is empty, so the matcher finds + /// nothing; and the honest verdict used to be [`LeaseKind::Untracked`] — no exit detection, and + /// `POST /game/end` with nothing to aim at. Playnite knew the whole time. + #[test] + fn a_reported_title_is_tracked_where_it_used_to_be_untracked() { + // The same request with no provider reporting: unchanged, and the control for what follows. + let l = open( + req("playnite:lease-test", DetectSpec::default(), false), + Box::new(|| {}), + ); + assert!(matches!(l.shared().kind(), LeaseKind::Untracked)); + assert!(!l.shared().is_trackable()); + drop(l); + + // A provider that speaks for the title — while reporting it NOT running, which is exactly + // what a report looks like at the moment a game is launched. Trackability follows from the + // provider *reporting*, not from what it currently says; a lease whose kind flipped with + // the answer would make both lifetime behaviours depend on a plugin's timing. + crate::runstate::report( + "playnite-lease-test", + ["playnite:lease-test".to_string()].into_iter().collect(), + std::collections::HashMap::new(), + ); + let l = open( + req("playnite:lease-test", DetectSpec::default(), false), + Box::new(|| {}), + ); + assert!(matches!(l.shared().kind(), LeaseKind::Reported)); + assert!( + l.shared().is_trackable(), + "so its exit is noticed and `POST /game/end` has a target" + ); + drop(l); + crate::runstate::forget("playnite-lease-test"); + + // …and once the provider is gone, so is the tracking. Pinned because a report that outlived + // its plugin is the one way this could hold a session open forever. + let l = open( + req("playnite:lease-test", DetectSpec::default(), false), + Box::new(|| {}), + ); + assert!(matches!(l.shared().kind(), LeaseKind::Untracked)); + } + #[test] fn an_untracked_lease_is_never_terminated() { let l = open( diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 6b0980f8..cfcb5854 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -105,6 +105,9 @@ mod plugins; // session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a // platform with neither (macOS, which has no launch path either) the module is an empty shell. mod procscan; +// The live half of the same binding: what a provider PLUGIN reports about its titles' liveness, +// where `procscan` can only look at the process table. +mod runstate; mod send_pacing; #[cfg(target_os = "windows")] #[path = "windows/service.rs"] diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 186c98a2..d4a37af6 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -372,6 +372,7 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { library::reconcile_provider_entries, library::delete_provider_entries )) + .routes(routes!(library::report_provider_running)) .routes(routes!(library::get_library_art)) .routes(routes!(stats::stats_capture_start)) .routes(routes!(stats::stats_capture_stop)) diff --git a/crates/punktfunk-host/src/mgmt/auth.rs b/crates/punktfunk-host/src/mgmt/auth.rs index 5e73521d..df6e914f 100644 --- a/crates/punktfunk-host/src/mgmt/auth.rs +++ b/crates/punktfunk-host/src/mgmt/auth.rs @@ -250,6 +250,10 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool { (&Method::DELETE, "/api/v1/library/custom/{}"), (&Method::PUT, "/api/v1/library/provider/{}"), (&Method::DELETE, "/api/v1/library/provider/{}"), + // Liveness reporting for a provider's OWN titles. No new authority: the host maps the + // report through the catalog, so a plugin can only ever speak about entries it published, + // and the worst a defective one can do to someone else's session is nothing at all. + (&Method::PUT, "/api/v1/library/provider/{}/running"), // Stats / telemetry. (&Method::POST, "/api/v1/stats/capture/start"), (&Method::POST, "/api/v1/stats/capture/stop"), diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index fc5f5ad0..92396aa3 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -607,12 +607,130 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path) -> Res if removed > 0 { tracing::info!(provider, removed, "library provider entries removed"); } + // Its entries are gone, so its opinions about them are meaningless — and a lease must + // never be held open by a provider that no longer exists. + crate::runstate::forget(&provider); Json(ProviderRemoved { removed }).into_response() } Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } +/// One running title in a provider's liveness report. +#[derive(Deserialize, ToSchema)] +pub(crate) struct RunningTitle { + /// The provider's own stable id for the title — the same key its reconcile payload uses. + pub external_id: String, + /// The process id the provider started for it, when it knows one. Optional, and never trusted + /// as a bare number: the host re-resolves it and pins it to its start time before it is ever + /// signalled, so a stale or recycled pid simply contributes nothing. + #[serde(default)] + pub pid: Option, +} + +/// Request body for `reportProviderRunning`. +#[derive(Deserialize, ToSchema)] +pub(crate) struct ProviderRunningInput { + /// Every title of this provider's that is running **right now**. The full set, not a delta: + /// anything absent from it is reported as stopped. + #[serde(default)] + pub running: Vec, +} + +/// The result of a liveness report. +#[derive(Serialize, ToSchema)] +pub(crate) struct ProviderRunningAccepted { + /// How many reported titles matched an entry this provider currently publishes. + matched: usize, + /// How many were ignored because no such entry exists (a report that raced a reconcile). + unknown: usize, + /// Seconds this report stays authoritative without being restated — re-report inside it while + /// anything is running. + ttl_s: u64, +} + +/// Report which of a provider's titles are running +/// +/// The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to +/// recognize* a title's process, this one says *it is running now* (design §9, +/// [`crate::runstate`]). For a provider that starts games itself and knows when they stop — +/// Playnite tracks every launch and fires an event on both edges — this is a fact the host would +/// otherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated +/// game, a manually added one) could not derive at all. +/// +/// Declarative and idempotent, like the reconcile: the body is the provider's **complete** running +/// set, so a missed event, a plugin restart or an install mid-game all self-correct on the next +/// report rather than drifting. +/// +/// The report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a +/// live provider to keep a streaming session open for a game the host cannot see: a plugin that +/// dies with a game running stops counting shortly after, and the host falls back to process +/// scanning exactly as it does without one. Re-report on every change **and** on a timer well +/// inside the window. +/// +/// Titles the provider does not currently publish are ignored (counted in `unknown`), not an error: +/// a report may legitimately race its own reconcile. +#[utoipa::path( + put, + path = "/library/provider/{provider}/running", + tag = "library", + operation_id = "reportProviderRunning", + params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")), + request_body = ProviderRunningInput, + responses( + (status = OK, description = "The report was accepted", body = ProviderRunningAccepted), + (status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn report_provider_running( + Path(provider): Path, + ApiJson(input): ApiJson, +) -> Response { + if let Err(e) = crate::library::validate_provider_name(&provider) { + return api_error(StatusCode::BAD_REQUEST, &e); + } + // Resolve the provider's own keys to the ids the rest of the host uses. A plugin knows its + // titles by `external_id`; a lease knows them by the library id the catalog assigned + // (`playnite:`), and only the catalog can map between the two — which is also what makes + // this authorization-safe, since a provider can only ever speak about entries it published. + let mine: Vec<(String, String)> = crate::library::load_custom() + .into_iter() + .filter(|e| e.provider.as_deref() == Some(provider.as_str())) + .filter_map(|e| { + let external = e.external_id.clone()?; + Some((external, crate::library::library_id_for(&e))) + }) + .collect(); + let owned: std::collections::HashSet = mine.iter().map(|(_, id)| id.clone()).collect(); + + let mut running = std::collections::HashMap::new(); + let mut unknown = 0usize; + for t in &input.running { + match mine.iter().find(|(external, _)| *external == t.external_id) { + Some((_, id)) => { + running.insert(id.clone(), t.pid); + } + None => unknown += 1, + } + } + let matched = running.len(); + tracing::debug!( + provider, + owned = owned.len(), + matched, + unknown, + "provider liveness report" + ); + crate::runstate::report(&provider, owned, running); + Json(ProviderRunningAccepted { + matched, + unknown, + ttl_s: crate::runstate::REPORT_TTL.as_secs(), + }) + .into_response() +} + /// Fetch one cover-art image for a library entry /// /// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index a69b58f4..dca01234 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -1440,6 +1440,16 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() { ("DELETE", "/api/v1/library/custom/{id}", true, false), ("PUT", "/api/v1/library/provider/{provider}", true, false), ("DELETE", "/api/v1/library/provider/{provider}", true, false), + // Liveness for a provider's own titles: the plugin lane's, like the reconcile beside it, + // and for the same reason — the host maps the report through the catalog, so a provider can + // only ever speak about entries it published. Never the cert lane: a streaming client has + // no titles of its own to report on. + ( + "PUT", + "/api/v1/library/provider/{provider}/running", + true, + false, + ), // ---- stats. ("POST", "/api/v1/stats/capture/start", true, false), ("POST", "/api/v1/stats/capture/stop", true, false), @@ -2935,3 +2945,54 @@ async fn provider_reconcile_validation() { let (s, _) = send(&app, del).await; assert_eq!(s, StatusCode::BAD_REQUEST); } + +/// Liveness reporting: the provider id is validated like every other provider write, and a title +/// the provider does not publish is *counted*, not refused. +/// +/// That tolerance is the point. A report races its own reconcile by construction — a game can start +/// before the entry that describes it has landed — and 400-ing the whole report over one unknown id +/// would throw away the liveness of every other running title, which is precisely the failure the +/// launcher-tile 400 taught us to avoid (`sanitize_launcher_entries`). The developer's real catalog +/// is not touched here, so every id in this test is `unknown` by construction — which is exactly +/// the case being pinned. +#[tokio::test] +async fn provider_running_report_validation() { + let app = test_app(test_state(), None); + let put = |provider: &str, body: serde_json::Value| { + axum::http::Request::put(format!("/api/v1/library/provider/{provider}/running")) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap() + }; + + let (s, json) = send(&app, put("manual", serde_json::json!({"running": []}))).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + assert!(json["error"].as_str().unwrap().contains("reserved")); + let (s, _) = send(&app, put("Bad%2FName", serde_json::json!({"running": []}))).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + + // An unreported provider is a legitimate report of "nothing is running". + let (s, json) = send(&app, put("playnite", serde_json::json!({"running": []}))).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(json["matched"], 0); + assert_eq!(json["unknown"], 0); + assert!(json["ttl_s"].as_u64().unwrap() > 0); + + // An id this provider does not publish is ignored, not an error. + let (s, json) = send( + &app, + put( + "playnite", + serde_json::json!({"running": [{"external_id": "no-such-title", "pid": 4242}]}), + ), + ) + .await; + assert_eq!(s, StatusCode::OK); + assert_eq!(json["matched"], 0); + assert_eq!(json["unknown"], 1); + + // A report leaves no opinion behind about a title nobody published, so nothing this test did + // can hold a real lease open. + assert!(!crate::runstate::speaks_for(Some("playnite:no-such-title"))); + crate::runstate::forget("playnite"); +} diff --git a/crates/punktfunk-host/src/runstate.rs b/crates/punktfunk-host/src/runstate.rs new file mode 100644 index 00000000..464f8a86 --- /dev/null +++ b/crates/punktfunk-host/src/runstate.rs @@ -0,0 +1,223 @@ +//! What a provider plugin **says** is running — the one liveness signal the host cannot work out +//! for itself. +//! +//! [`crate::procscan`] answers "is this game running" by looking at the process table, and +//! [`crate::gamelease`] turns that into a session lifetime. That works because most stores leave +//! something recognizable behind: an install directory, an executable, a Steam reaper. Some do not, +//! and one store in particular *already knows the answer*: Playnite starts the game itself, tracks +//! it with the mode the person configured (process, directory, original-process), and fires an +//! event on both edges — carrying the pid it started. Every bit of that was being thrown away, and +//! the host was left re-deriving a worse version of it by scanning. +//! +//! So this is the inbound half of [`crate::library::DetectHint`]. That one is *static* ("here is +//! how to recognize my title's process"); this one is *live* ("that title is running right now, and +//! here is its pid"). A provider PUTs its full running set; the host keeps it here; the lease +//! watcher consults it. +//! +//! ### Why the whole set, and why a TTL +//! +//! The wire is declarative — the same shape as the library reconcile, for the same reason. A +//! provider that missed an event, restarted, or was installed mid-game converges on its next PUT +//! instead of drifting forever; there is no per-event delta to lose. +//! +//! And a report **expires**. A plugin that dies with a game running would otherwise leave a claim +//! that is true today and a lie tomorrow — and unlike Steam's registry flag (which +//! [`crate::procscan::running_hint`] must treat as merely a bounded veto because Steam leaves it +//! set on any unclean exit) this claim is allowed to *keep a session alive on its own*. That is +//! only safe while something is actively restating it, so a report older than [`REPORT_TTL`] stops +//! counting and the host falls back to scanning, exactly as it does today. The provider's side of +//! that bargain is to re-PUT well inside the window while anything is running. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, Instant}; + +/// How long a provider's report stays authoritative without being restated. +/// +/// Generous enough that a plugin refreshing every 30s survives a slow reconcile or a paused runner, +/// short enough that a *dead* plugin stops vetoing a session end within a couple of minutes. The +/// cost of expiring too early is the pre-existing behaviour (scan-only); the cost of never expiring +/// is a session that can never end on its own, which is the bug this whole area exists to kill. +pub const REPORT_TTL: Duration = Duration::from_secs(90); + +/// What a provider says about one of its titles. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Liveness { + /// Whether the provider lists this title as running right now. + pub running: bool, + /// The pid the provider started for it, when it knows one. Never trusted as a bare number — + /// every use re-verifies it through [`crate::procscan`], which pins it to its start time. + pub pid: Option, +} + +/// One provider's most recent report. +struct Report { + /// When it landed — the TTL clock. + at: Instant, + /// Every library id this provider speaks for. What makes "not in `running`" mean *not running* + /// rather than *no opinion*: without it an omitted title is indistinguishable from a title + /// belonging to some other provider entirely. + owned: HashSet, + /// The subset that is running, each with the pid the provider started (when it has one). + running: HashMap>, +} + +impl Report { + fn fresh(&self) -> bool { + self.at.elapsed() < REPORT_TTL + } +} + +fn table() -> MutexGuard<'static, HashMap> { + static TABLE: OnceLock>> = OnceLock::new(); + TABLE + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +/// Record a provider's report, replacing whatever it said before. +/// +/// `owned` is every library id the provider currently publishes; `running` is the subset that is +/// running, keyed the same way, valued by pid where one is known. +pub fn report(provider: &str, owned: HashSet, running: HashMap>) { + table().insert( + provider.to_string(), + Report { + at: Instant::now(), + owned, + running, + }, + ); +} + +/// Forget everything a provider said — its entries are gone, so its opinions are meaningless. +pub fn forget(provider: &str) { + table().remove(provider); +} + +/// What a *fresh* provider says about this library id, or `None` when none speaks for it. +/// +/// `None` is the answer for every title on a host with no reporting plugin, which is what keeps +/// this entirely inert until someone opts in. +pub fn opinion(app_id: &str) -> Option { + let table = table(); + table + .values() + .filter(|r| r.fresh()) + .find(|r| r.owned.contains(app_id)) + .map(|r| match r.running.get(app_id) { + Some(pid) => Liveness { + running: true, + pid: *pid, + }, + None => Liveness { + running: false, + pid: None, + }, + }) +} + +/// Whether any fresh provider reports liveness for this title at all — regardless of what it +/// currently says. +/// +/// Asked once, when a lease opens: a title whose provider will tell us when it stops is trackable +/// even with no detect signals whatsoever, which is the whole point (see +/// [`crate::gamelease::LeaseKind::Reported`]). +pub fn speaks_for(app_id: Option<&str>) -> bool { + app_id.is_some_and(|id| opinion(id).is_some()) +} + +/// Drop every report. Test-only: the table is process-global, so a test that seeds it must be able +/// to unseed it. +#[cfg(test)] +pub fn reset() { + table().clear(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn owned(ids: &[&str]) -> HashSet { + ids.iter().map(|s| (*s).to_string()).collect() + } + + fn running(ids: &[(&str, Option)]) -> HashMap> { + ids.iter().map(|(s, p)| ((*s).to_string(), *p)).collect() + } + + /// The three answers, and the distinction the whole module turns on: a title its provider omits + /// is *not running*, while a title nobody speaks for has *no opinion*. Conflating them would + /// make every unreported game on the box look like it had just quit. + #[test] + fn omitted_is_not_running_but_unknown_is_no_opinion() { + reset(); + report( + "playnite", + owned(&["playnite:a", "playnite:b"]), + running(&[("playnite:a", Some(4242))]), + ); + assert_eq!( + opinion("playnite:a"), + Some(Liveness { + running: true, + pid: Some(4242) + }) + ); + assert_eq!( + opinion("playnite:b"), + Some(Liveness { + running: false, + pid: None + }) + ); + assert_eq!(opinion("steam:570"), None); + assert!(speaks_for(Some("playnite:b"))); + assert!(!speaks_for(Some("steam:570"))); + assert!(!speaks_for(None)); + reset(); + } + + /// A report replaces its predecessor wholesale. The set is the message: a title that dropped out + /// of it has stopped, and carrying the old entry forward would be exactly the stuck-running + /// state this exists to prevent. + #[test] + fn a_report_replaces_the_previous_one() { + reset(); + report( + "playnite", + owned(&["playnite:a"]), + running(&[("playnite:a", None)]), + ); + report("playnite", owned(&["playnite:a"]), running(&[])); + assert_eq!( + opinion("playnite:a"), + Some(Liveness { + running: false, + pid: None + }) + ); + forget("playnite"); + assert_eq!(opinion("playnite:a"), None); + reset(); + } + + /// A stale report stops counting — the bound that makes it safe to let a plugin's claim hold a + /// session open. Seeded with an aged timestamp rather than by sleeping for 90 seconds. + #[test] + fn a_stale_report_has_no_opinion() { + reset(); + table().insert( + "playnite".to_string(), + Report { + at: Instant::now() - REPORT_TTL - Duration::from_secs(1), + owned: owned(&["playnite:a"]), + running: running(&[("playnite:a", Some(7))]), + }, + ); + assert_eq!(opinion("playnite:a"), None); + assert!(!speaks_for(Some("playnite:a"))); + reset(); + } +} diff --git a/plugin-kit/src/reconcile.ts b/plugin-kit/src/reconcile.ts index 391e922e..8725aece 100644 --- a/plugin-kit/src/reconcile.ts +++ b/plugin-kit/src/reconcile.ts @@ -9,6 +9,14 @@ import type { ProviderEntry } from "./wire.js"; export * from "./wire.js"; +/** + * The host's liveness-report TTL, in seconds, when it does not say. + * + * Only a fallback for parsing an unexpected answer — the authority is the `ttlS` the host returns. + * A reporter should refresh at a fraction of this, so one missed call is not a lapse. + */ +export const DEFAULT_RUNNING_TTL_S = 90; + /** What the host echoed back for one reconciled entry — enough to tell whether a claim took. */ export interface ReconciledEntry { readonly id: string; @@ -35,6 +43,36 @@ export interface ProviderClientService { entries: ReadonlyArray, store?: string, ) => Effect.Effect, HostRequestError>; + /** + * Report which of this provider's titles are running **right now** — the live counterpart to the + * static `detect` hints in {@link reconcile}. + * + * `detect` says *how to recognize* a title's process; this says *it is running*, and carries the + * pid where the provider knows one. For a launcher that starts games itself and is told when + * they stop, this is a fact the host would otherwise re-derive by scanning — and for a title + * with nothing to scan for (an emulated game, a manually added one, a launcher that records no + * install directory) could not derive at all: its lease is `untracked`, its exit is never + * noticed, and the streaming session outlives the game. + * + * **Send the complete set, not a delta.** Anything absent is reported stopped, so a missed + * event, a plugin restart or an install mid-game all self-correct on the next call. + * + * **The host expires a report** (`ttlS` in the answer, 90s at the time of writing) unless it is + * restated — which is what makes it safe for the host to keep a session open for a game it + * cannot see. Call this on every change **and** on a timer well inside that window while + * anything is running; a plugin that stops reporting simply hands tracking back to the host's + * process scan. + * + * Titles the host has no entry for are counted in `unknown`, not refused: a report may + * legitimately race its own reconcile. + * + * Fails on a host that predates the route (404) — treat that as "this host tracks games by + * scanning" and carry on, exactly as with any other optional capability. + */ + readonly reportRunning: ( + providerId: string, + running: ReadonlyArray, + ) => Effect.Effect; /** * Remove every entry this provider owns **and release its store claim** (the explicit-uninstall * path). Releasing is what brings the host's built-in scanner back. @@ -44,6 +82,29 @@ export interface ProviderClientService { ) => Effect.Effect; } +/** One running title in a {@link ProviderClientService.reportRunning} call. */ +export interface RunningTitle { + /** The provider's own stable id — the same key its reconcile payload uses. */ + readonly external_id: string; + /** + * The process the provider started for it, when it knows one. Optional, and never trusted as a + * bare number: the host re-resolves it and pins it to its start time before it is ever + * signalled, so a stale or recycled pid contributes nothing. Worth sending anyway — it is what + * gives "End game" something to aim at for a title the host's matcher cannot find. + */ + readonly pid?: number; +} + +/** What the host answered to a liveness report. */ +export interface RunningAccepted { + /** How many reported titles matched an entry this provider currently publishes. */ + readonly matched: number; + /** How many were ignored because no such entry exists (a report that raced a reconcile). */ + readonly unknown: number; + /** Seconds the report stays authoritative without being restated. */ + readonly ttlS: number; +} + export class ProviderClient extends Context.Service< ProviderClient, ProviderClientService @@ -72,6 +133,27 @@ export class ProviderClient extends Context.Service< : [], ), ), + reportRunning: (providerId, running) => + host + .request("PUT", `/library/provider/${providerId}/running`, { + running, + }) + .pipe( + // Same posture as the reconcile echo above: the counts are a + // diagnostic, not a contract, so a host that answers something + // unexpected must not fail a plugin's report loop. The TTL falls + // back to the host's own documented default. + Effect.map((body) => { + const b = (body ?? {}) as Record; + const num = (v: unknown, fallback: number) => + typeof v === "number" && Number.isFinite(v) ? v : fallback; + return { + matched: num(b.matched, 0), + unknown: num(b.unknown, 0), + ttlS: num(b.ttl_s, DEFAULT_RUNNING_TTL_S), + } satisfies RunningAccepted; + }), + ), remove: (providerId) => host .request("DELETE", `/library/provider/${providerId}`) From 1758266bda8475ee14c1456bff6e74499c205031 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 20 Aug 2026 19:32:22 +0200 Subject: [PATCH 2/3] chore(plugin-kit): export the running-report surface, and bump to 0.4.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reportRunning` and its two types were reachable only through the deep `./reconcile.js` path — `index.ts` re-exports an explicit list, not a star — so no plugin could import them from the package root the way it imports every other provider symbol. Version bumped because it is a published package and the addition is what a consumer would depend on; the playnite plugin deliberately does NOT, calling the route through the untyped host seam instead so it is not gated on this publish. --- CHANGELOG.md | 11 ++++++++--- plugin-kit/package.json | 2 +- plugin-kit/src/index.ts | 3 +++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c218fb1..837e008f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -576,10 +576,15 @@ and fires an event on both edges carrying the pid. That was being thrown away. throw away the liveness of every other running title. - **`@punktfunk/plugin-kit`: `ProviderClient.reportRunning(providerId, running)`**, returning `{matched, unknown, ttlS}`; a 404 from an older host means "this host tracks games by scanning". - Rides the owed `plugin-kit-v0.4.3` publish. + Version bumped to **0.4.4** — **unpublished, `plugin-kit-v0.4.4` owed.** -The Playnite half (the C# exporter hooking `OnGameStarted`/`OnGameStopped` and the plugin relaying -it on a heartbeat) lives in `punktfunk-plugin-playnite` and needs a host carrying this route. +The Playnite half lives in `punktfunk-plugin-playnite` (**0.4.5**, exporter **0.4.0**): the C# +exporter hooks Playnite's `OnGameStarted`/`OnGameStopped`/`OnGameStartupCancelled` and writes a +small `punktfunk-running.json` beside the library export, re-stamped every 30 s and *deleted* when +Playnite closes; the plugin polls it and restates the set to this route. It calls the route through +the kit's untyped host seam rather than `reportRunning`, deliberately — depending on the method +would make that repo unbuildable until the kit publishes, for the same request. Needs a host +carrying this route; an older one 404s and the plugin carries on without it. ### Everything else an integrator might notice diff --git a/plugin-kit/package.json b/plugin-kit/package.json index b661292e..a3759da8 100644 --- a/plugin-kit/package.json +++ b/plugin-kit/package.json @@ -1,6 +1,6 @@ { "name": "@punktfunk/plugin-kit", - "version": "0.4.3", + "version": "0.4.4", "description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.", "type": "module", "license": "MIT OR Apache-2.0", diff --git a/plugin-kit/src/index.ts b/plugin-kit/src/index.ts index 3dca08fa..8eb868d2 100644 --- a/plugin-kit/src/index.ts +++ b/plugin-kit/src/index.ts @@ -22,6 +22,7 @@ export { } from "./paths.js"; export { Artwork, + DEFAULT_RUNNING_TTL_S, DetectHint, GameMeta, LaunchSpec, @@ -29,6 +30,8 @@ export { ProviderClient, type ProviderClientService, ProviderEntry, + type RunningAccepted, + type RunningTitle, } from "./reconcile.js"; export { definePluginKit, From 8ff6fe609318a0eec1c81a08e031bc9f81a97e07 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 20 Aug 2026 19:45:10 +0200 Subject: [PATCH 3/3] fix(host): regenerate the API spec, and stop the runstate tests colliding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `api/openapi.json` (and its docs-site copy) gain exactly the new route and its three schemas — nothing else moved, which is the check worth doing on a regenerated spec. The test fix is one the tests found themselves, on the first run in an environment that actually executes them: all three shared the provider id `playnite` and cleared the whole process-global table between cases, so under parallel scheduling they flipped each other's answers — `omitted_is_not_running` read `None` for a title another test had just wiped. Each now takes ids only it uses and forgets only its own row, which also retires the blunt `reset()` that made the collision possible. --- api/openapi.json | 125 ++++++++++++++++++++++++++ crates/punktfunk-host/src/runstate.rs | 60 ++++++------- docs-site/public/openapi.json | 125 ++++++++++++++++++++++++++ 3 files changed, 277 insertions(+), 33 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 0b71db0a..aab01b82 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1860,6 +1860,69 @@ } } }, + "/api/v1/library/provider/{provider}/running": { + "put": { + "tags": [ + "library" + ], + "summary": "Report which of a provider's titles are running", + "description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.", + "operationId": "reportProviderRunning", + "parameters": [ + { + "name": "provider", + "in": "path", + "description": "The provider id ([a-z0-9._-], `manual` reserved)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderRunningInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The report was accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderRunningAccepted" + } + } + } + }, + "400": { + "description": "Invalid provider id or payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/library/scanners": { "get": { "tags": [ @@ -7792,6 +7855,46 @@ } } }, + "ProviderRunningAccepted": { + "type": "object", + "description": "The result of a liveness report.", + "required": [ + "matched", + "unknown", + "ttl_s" + ], + "properties": { + "matched": { + "type": "integer", + "description": "How many reported titles matched an entry this provider currently publishes.", + "minimum": 0 + }, + "ttl_s": { + "type": "integer", + "format": "int64", + "description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.", + "minimum": 0 + }, + "unknown": { + "type": "integer", + "description": "How many were ignored because no such entry exists (a report that raced a reconcile).", + "minimum": 0 + } + } + }, + "ProviderRunningInput": { + "type": "object", + "description": "Request body for `reportProviderRunning`.", + "properties": { + "running": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RunningTitle" + }, + "description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped." + } + } + }, "ReleaseDisplayRequest": { "type": "object", "description": "Request body for `releaseDisplay`.", @@ -7846,6 +7949,28 @@ } } }, + "RunningTitle": { + "type": "object", + "description": "One running title in a provider's liveness report.", + "required": [ + "external_id" + ], + "properties": { + "external_id": { + "type": "string", + "description": "The provider's own stable id for the title — the same key its reconcile payload uses." + }, + "pid": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.", + "minimum": 0 + } + } + }, "RuntimeRequest": { "type": "object", "required": [ diff --git a/crates/punktfunk-host/src/runstate.rs b/crates/punktfunk-host/src/runstate.rs index 464f8a86..eefba7e9 100644 --- a/crates/punktfunk-host/src/runstate.rs +++ b/crates/punktfunk-host/src/runstate.rs @@ -128,13 +128,6 @@ pub fn speaks_for(app_id: Option<&str>) -> bool { app_id.is_some_and(|id| opinion(id).is_some()) } -/// Drop every report. Test-only: the table is process-global, so a test that seeds it must be able -/// to unseed it. -#[cfg(test)] -pub fn reset() { - table().clear(); -} - #[cfg(test)] mod tests { use super::*; @@ -147,36 +140,40 @@ mod tests { ids.iter().map(|(s, p)| ((*s).to_string(), *p)).collect() } + // The table is process-global and these tests run in parallel, so each takes a provider id and + // app ids only it uses, and cleans up only its own row. An earlier draft shared the id + // `playnite` and cleared the whole table between cases, which made the three of them flip each + // other's answers depending on scheduling — the same shape as `mgmt`'s `local_summary` race. + /// The three answers, and the distinction the whole module turns on: a title its provider omits /// is *not running*, while a title nobody speaks for has *no opinion*. Conflating them would /// make every unreported game on the box look like it had just quit. #[test] fn omitted_is_not_running_but_unknown_is_no_opinion() { - reset(); report( - "playnite", - owned(&["playnite:a", "playnite:b"]), - running(&[("playnite:a", Some(4242))]), + "answers-test", + owned(&["answers:a", "answers:b"]), + running(&[("answers:a", Some(4242))]), ); assert_eq!( - opinion("playnite:a"), + opinion("answers:a"), Some(Liveness { running: true, pid: Some(4242) }) ); assert_eq!( - opinion("playnite:b"), + opinion("answers:b"), Some(Liveness { running: false, pid: None }) ); - assert_eq!(opinion("steam:570"), None); - assert!(speaks_for(Some("playnite:b"))); - assert!(!speaks_for(Some("steam:570"))); + assert_eq!(opinion("answers:never-published"), None); + assert!(speaks_for(Some("answers:b"))); + assert!(!speaks_for(Some("answers:never-published"))); assert!(!speaks_for(None)); - reset(); + forget("answers-test"); } /// A report replaces its predecessor wholesale. The set is the message: a title that dropped out @@ -184,40 +181,37 @@ mod tests { /// state this exists to prevent. #[test] fn a_report_replaces_the_previous_one() { - reset(); report( - "playnite", - owned(&["playnite:a"]), - running(&[("playnite:a", None)]), + "replace-test", + owned(&["replace:a"]), + running(&[("replace:a", None)]), ); - report("playnite", owned(&["playnite:a"]), running(&[])); + report("replace-test", owned(&["replace:a"]), running(&[])); assert_eq!( - opinion("playnite:a"), + opinion("replace:a"), Some(Liveness { running: false, pid: None }) ); - forget("playnite"); - assert_eq!(opinion("playnite:a"), None); - reset(); + forget("replace-test"); + assert_eq!(opinion("replace:a"), None); } /// A stale report stops counting — the bound that makes it safe to let a plugin's claim hold a /// session open. Seeded with an aged timestamp rather than by sleeping for 90 seconds. #[test] fn a_stale_report_has_no_opinion() { - reset(); table().insert( - "playnite".to_string(), + "stale-test".to_string(), Report { at: Instant::now() - REPORT_TTL - Duration::from_secs(1), - owned: owned(&["playnite:a"]), - running: running(&[("playnite:a", Some(7))]), + owned: owned(&["stale:a"]), + running: running(&[("stale:a", Some(7))]), }, ); - assert_eq!(opinion("playnite:a"), None); - assert!(!speaks_for(Some("playnite:a"))); - reset(); + assert_eq!(opinion("stale:a"), None); + assert!(!speaks_for(Some("stale:a"))); + forget("stale-test"); } } diff --git a/docs-site/public/openapi.json b/docs-site/public/openapi.json index 0b71db0a..aab01b82 100644 --- a/docs-site/public/openapi.json +++ b/docs-site/public/openapi.json @@ -1860,6 +1860,69 @@ } } }, + "/api/v1/library/provider/{provider}/running": { + "put": { + "tags": [ + "library" + ], + "summary": "Report which of a provider's titles are running", + "description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.", + "operationId": "reportProviderRunning", + "parameters": [ + { + "name": "provider", + "in": "path", + "description": "The provider id ([a-z0-9._-], `manual` reserved)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderRunningInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The report was accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderRunningAccepted" + } + } + } + }, + "400": { + "description": "Invalid provider id or payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/library/scanners": { "get": { "tags": [ @@ -7792,6 +7855,46 @@ } } }, + "ProviderRunningAccepted": { + "type": "object", + "description": "The result of a liveness report.", + "required": [ + "matched", + "unknown", + "ttl_s" + ], + "properties": { + "matched": { + "type": "integer", + "description": "How many reported titles matched an entry this provider currently publishes.", + "minimum": 0 + }, + "ttl_s": { + "type": "integer", + "format": "int64", + "description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.", + "minimum": 0 + }, + "unknown": { + "type": "integer", + "description": "How many were ignored because no such entry exists (a report that raced a reconcile).", + "minimum": 0 + } + } + }, + "ProviderRunningInput": { + "type": "object", + "description": "Request body for `reportProviderRunning`.", + "properties": { + "running": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RunningTitle" + }, + "description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped." + } + } + }, "ReleaseDisplayRequest": { "type": "object", "description": "Request body for `releaseDisplay`.", @@ -7846,6 +7949,28 @@ } } }, + "RunningTitle": { + "type": "object", + "description": "One running title in a provider's liveness report.", + "required": [ + "external_id" + ], + "properties": { + "external_id": { + "type": "string", + "description": "The provider's own stable id for the title — the same key its reconcile payload uses." + }, + "pid": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.", + "minimum": 0 + } + } + }, "RuntimeRequest": { "type": "object", "required": [