Merge pull request 'A provider plugin can report which of its titles are running' (#361) from worktree-playnite-runstate into main

Reviewed-on: unom/punktfunk#361
This commit is contained in:
2026-08-20 19:22:10 +00:00
13 changed files with 969 additions and 37 deletions
+49
View File
@@ -537,6 +537,55 @@ 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".
Version bumped to **0.4.4** — **unpublished, `plugin-kit-v0.4.4` owed.**
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
- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin,
+125
View File
@@ -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": [
+180 -36
View File
@@ -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<crate::procscan::ProcRef> {
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(
+3
View File
@@ -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"]
+1
View File
@@ -372,6 +372,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, 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))
+4
View File
@@ -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"),
+118
View File
@@ -607,12 +607,130 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> 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<u32>,
}
/// 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<RunningTitle>,
}
/// 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<String>,
ApiJson(input): ApiJson<ProviderRunningInput>,
) -> 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:<guid>`), 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<String> = 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
+61
View File
@@ -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");
}
+217
View File
@@ -0,0 +1,217 @@
//! 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<u32>,
}
/// 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<String>,
/// The subset that is running, each with the pid the provider started (when it has one).
running: HashMap<String, Option<u32>>,
}
impl Report {
fn fresh(&self) -> bool {
self.at.elapsed() < REPORT_TTL
}
}
fn table() -> MutexGuard<'static, HashMap<String, Report>> {
static TABLE: OnceLock<Mutex<HashMap<String, Report>>> = 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<String>, running: HashMap<String, Option<u32>>) {
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<Liveness> {
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())
}
#[cfg(test)]
mod tests {
use super::*;
fn owned(ids: &[&str]) -> HashSet<String> {
ids.iter().map(|s| (*s).to_string()).collect()
}
fn running(ids: &[(&str, Option<u32>)]) -> HashMap<String, Option<u32>> {
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() {
report(
"answers-test",
owned(&["answers:a", "answers:b"]),
running(&[("answers:a", Some(4242))]),
);
assert_eq!(
opinion("answers:a"),
Some(Liveness {
running: true,
pid: Some(4242)
})
);
assert_eq!(
opinion("answers:b"),
Some(Liveness {
running: false,
pid: None
})
);
assert_eq!(opinion("answers:never-published"), None);
assert!(speaks_for(Some("answers:b")));
assert!(!speaks_for(Some("answers:never-published")));
assert!(!speaks_for(None));
forget("answers-test");
}
/// 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() {
report(
"replace-test",
owned(&["replace:a"]),
running(&[("replace:a", None)]),
);
report("replace-test", owned(&["replace:a"]), running(&[]));
assert_eq!(
opinion("replace:a"),
Some(Liveness {
running: false,
pid: None
})
);
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() {
table().insert(
"stale-test".to_string(),
Report {
at: Instant::now() - REPORT_TTL - Duration::from_secs(1),
owned: owned(&["stale:a"]),
running: running(&[("stale:a", Some(7))]),
},
);
assert_eq!(opinion("stale:a"), None);
assert!(!speaks_for(Some("stale:a")));
forget("stale-test");
}
}
+125
View File
@@ -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": [
+1 -1
View File
@@ -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",
+3
View File
@@ -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,
+82
View File
@@ -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<ProviderEntry>,
store?: string,
) => Effect.Effect<ReadonlyArray<ReconciledEntry>, 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<RunningTitle>,
) => Effect.Effect<RunningAccepted, HostRequestError>;
/**
* 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<void, HostRequestError>;
}
/** 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<string, unknown>;
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}`)